Merge branch 'main' into chat-improvements

This commit is contained in:
Tommy Chow 2024-09-01 15:37:54 -07:00
commit 3069cd93ab
99 changed files with 2498 additions and 1463 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
github: tommyxchow
custom: ["buymeacoffee.com/tommychow", "paypal.com/donate/?business=NF33JDG6KBU6W"]
custom: buymeacoffee.com/tommychow

123
.gitignore vendored
View file

@ -1,63 +1,62 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Folder containing random testing files
random/
# fastlane specific
**/fastlane/report.xml
# deliver temporary files
**/fastlane/Preview.html
# snapshot generated screenshots
**/fastlane/screenshots
**/fastlane/metadata/android/en-US/images
# scan temporary files
**/fastlane/test_output
# potentially sensitive review information
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Folder containing random testing files
random/
# fastlane specific
**/fastlane/report.xml
# deliver temporary files
**/fastlane/Preview.html
# snapshot generated screenshots
**/fastlane/screenshots
**/fastlane/metadata/android/en-US/images
# scan temporary files
**/fastlane/test_output
# potentially sensitive review information
**/fastlane/metadata/review_information

View file

@ -98,9 +98,8 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/).
If you appreciate my work and would like to donate/tip, you can through:
- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow)
- [GitHub Sponsors](https://github.com/sponsors/tommyxchow)
- [PayPal](https://www.paypal.com/donate/?business=NF33JDG6KBU6W)
- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow)
Otherwise, downloading Frosty, leaving a review, or starring this repository is more than enough to show support. Thank you!

View file

@ -1,39 +1,38 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
- directives_ordering
- prefer_single_quotes
- always_use_package_imports
- avoid_void_async
- prefer_final_locals
- prefer_final_in_for_each
- avoid_redundant_argument_values
- unnecessary_parenthesis
- always_declare_return_types
- require_trailing_commas
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
- directives_ordering
- prefer_single_quotes
- always_use_package_imports
- avoid_void_async
- prefer_final_locals
- prefer_final_in_for_each
- avoid_redundant_argument_values
- unnecessary_parenthesis
- always_declare_return_types
- require_trailing_commas
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

2
android/.gitignore vendored
View file

@ -7,7 +7,7 @@ gradle-wrapper.jar
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View file

@ -1,29 +1,32 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.854.0)
aws-sdk-core (3.187.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-eventstream (1.3.0)
aws-partitions (1.963.0)
aws-sdk-core (3.201.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.72.0)
aws-sdk-core (~> 3, >= 3.184.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.137.0)
aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -36,7 +39,7 @@ GEM
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.104.0)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@ -58,22 +61,22 @@ GEM
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.217.0)
fastimage (2.3.1)
fastlane (2.222.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
@ -85,6 +88,7 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
@ -93,10 +97,10 @@ GEM
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
@ -105,11 +109,11 @@ GEM
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.53.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.2)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -117,15 +121,14 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
@ -145,33 +148,36 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
http-cookie (1.0.6)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
mini_magick (4.12.0)
json (2.7.2)
jwt (2.8.2)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.3.0)
multipart-post (2.4.1)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
nkf (0.2.0)
optparse (0.5.0)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.4)
rake (13.1.0)
plist (3.7.1)
public_suffix (5.1.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rexml (3.3.5)
strscan
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
security (0.1.5)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@ -180,12 +186,13 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
@ -193,15 +200,14 @@ GEM
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.23.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)

View file

@ -1,86 +1,60 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
// START: FlutterFire Configuration
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.firebase-perf'
apply plugin: 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace "com.tommychow.frosty"
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.tommychow.frosty"
minSdkVersion 26
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
plugins {
id "com.android.application"
// START: FlutterFire Configuration
id 'com.google.gms.google-services'
id 'com.google.firebase.firebase-perf'
id 'com.google.firebase.crashlytics'
// END: FlutterFire Configuration
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.tommychow.frosty"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "com.tommychow.frosty"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
release {
keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties["keyPassword"]
storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null
storePassword keystoreProperties["storePassword"]
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
flutter {
source = "../.."
}

View file

@ -1,45 +1,57 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="Frosty"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="Frosty"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

View file

@ -1,36 +1,18 @@
buildscript {
ext.kotlin_version = '1.9.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
// START: FlutterFire Configuration
classpath 'com.google.gms:google-services:4.3.15'
classpath 'com.google.firebase:perf-plugin:1.4.2'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.5'
// END: FlutterFire Configuration
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,10 @@
New Features
• Added the ability to tap on user mentions in chat (by @Artiu on GitHub)
• Added stream latency details to the custom overlay in landscape mode (by @Artiu on GitHub)
Improvements
• Improved how the default Twitch overlay is hidden when the custom overlay is enabled (by @Artiu on GitHub)
• Links in chat are now also underlined
Bug Fixes
• Fixed 7TV emote set updates not respecting the chat delay setting

View file

@ -0,0 +1,4 @@
Bug Fixes
• Fixed stream quality options not showing (by @Shingyx on GitHub)
• Fixed stream latency not showing (by @Shingyx on GitHub)
• Fixed stream latency icon color on light mode

View file

@ -0,0 +1 @@
This update contains a variety of new features including a quick toggle to enter or exit chat-only mode, the ability to zoom stream thumbnails and emotes or badges, an option to show historical recent messages in chat, and other improvements and bug fixes.

View file

@ -0,0 +1,9 @@
New Features
• Added an option to un-darken historical recent messages in chat
Improvements
• Moved exact matches of searched categories to the top (by @Shingyx on GitHub)
Bug Fixes
• Fixed extraneous notices when toggling chat-only mode
• Fixed cookie banner blocking login and added backup workaround instructions in the login screen toolbar (instructions by @UmmonTeal on GitHub)

View file

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View file

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip

View file

@ -1,11 +1,28 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
id "com.google.firebase.crashlytics" version "2.9.9" apply false
}
include ":app"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

45
assets/release-notes.md Normal file
View file

@ -0,0 +1,45 @@
## v4.5.0
August 16, 2024
### New Features
- Added an option to un-darken historical recent messages in chat
### Improvements
- [iPadOS] Enabled video quality settings on iPad (by @Shingyx on GitHub)
- Moved exact matches of searched categories to the top (by @Shingyx on GitHub)
### Bug Fixes
- Fixed extraneous notices when toggling chat-only mode
- Fixed cookie banner blocking login and added backup workaround instructions in the login screen toolbar (instructions by @UmmonTeal on GitHub)
---
## v4.4.0
August 13, 2024
### New Features
- Added a quick toggle in the chat details menu to enter or exit chat-only mode
- Added an experimental option to sync message delay with stream latency
- Added options to individually disable emotes or badges from Twitch, 7TV, BTTV, and FFZ
- Added the ability to view and zoom emotes or badges when tapping on them
- Added the ability to view and zoom stream thumbnails when long-pressing on it
- Added an option to show historical recent messages in chat
- Added latest release notes on launch
### Improvements
- [Android] In-app links will now prefer Android Custom Tabs
- Decreased the default landscape chat width from 30% to 25%
### Bug Fixes
- Fixed animated FFZ emotes not animating
- Fixed rare case where the custom video overlay doesn't enter the playing state
#### See all release notes at https://github.com/tommyxchow/frosty/releases

22
firebase.json Normal file
View file

@ -0,0 +1,22 @@
{
"flutter": {
"platforms": {
"android": {
"default": {
"projectId": "frosty-128de",
"appId": "1:403282332389:android:a7ff237168bad4c30be20c",
"fileOutput": "android/app/google-services.json"
}
},
"dart": {
"lib/firebase_options.dart": {
"projectId": "frosty-128de",
"configurations": {
"android": "1:403282332389:android:a7ff237168bad4c30be20c",
"ios": "1:403282332389:ios:0fc3f8c3d4e80c8b0be20c"
}
}
}
}
}
}

View file

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
<string>12.0</string>
</dict>
</plist>

View file

@ -1,29 +1,32 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.854.0)
aws-sdk-core (3.187.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-eventstream (1.3.0)
aws-partitions (1.963.0)
aws-sdk-core (3.201.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.72.0)
aws-sdk-core (~> 3, >= 3.184.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.137.0)
aws-sdk-core (~> 3, >= 3.181.0)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -36,7 +39,7 @@ GEM
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.104.0)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@ -58,22 +61,22 @@ GEM
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.217.0)
fastimage (2.3.1)
fastlane (2.222.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
@ -85,6 +88,7 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
@ -93,10 +97,10 @@ GEM
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
@ -105,11 +109,11 @@ GEM
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.53.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.2)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -117,15 +121,14 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
@ -145,33 +148,36 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
http-cookie (1.0.6)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
mini_magick (4.12.0)
json (2.7.2)
jwt (2.8.2)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.3.0)
multipart-post (2.4.1)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
nkf (0.2.0)
optparse (0.5.0)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.4)
rake (13.1.0)
plist (3.7.1)
public_suffix (5.1.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rexml (3.3.5)
strscan
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
security (0.1.5)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@ -180,12 +186,13 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
@ -193,15 +200,14 @@ GEM
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.23.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -16,22 +16,22 @@ PODS:
- Firebase/Performance (10.18.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 10.18.0)
- firebase_analytics (10.7.2):
- firebase_analytics (10.8.0):
- Firebase/Analytics (= 10.18.0)
- firebase_core
- Flutter
- firebase_core (2.24.0):
- firebase_core (2.24.2):
- Firebase/CoreOnly (= 10.18.0)
- Flutter
- firebase_crashlytics (3.4.6):
- firebase_crashlytics (3.4.9):
- Firebase/Crashlytics (= 10.18.0)
- firebase_core
- Flutter
- firebase_performance (0.9.3-6):
- firebase_performance (0.9.3-8):
- Firebase/Performance (= 10.18.0)
- firebase_core
- Flutter
- FirebaseABTesting (10.18.0):
- FirebaseABTesting (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.18.0):
- FirebaseAnalytics/AdIdSupport (= 10.18.0)
@ -55,9 +55,9 @@ PODS:
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.12)
- GoogleUtilities/Logger (~> 7.12)
- FirebaseCoreExtension (10.18.0):
- FirebaseCoreExtension (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.18.0):
- FirebaseCoreInternal (10.29.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseCrashlytics (10.18.0):
- FirebaseCore (~> 10.5)
@ -67,7 +67,7 @@ PODS:
- GoogleUtilities/Environment (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (~> 2.1)
- FirebaseInstallations (10.18.0):
- FirebaseInstallations (10.29.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
@ -82,30 +82,30 @@ PODS:
- GoogleUtilities/ISASwizzler (~> 7.8)
- GoogleUtilities/MethodSwizzler (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.18.0):
- FirebaseRemoteConfig (10.29.0):
- FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfigInterop (~> 10.23)
- FirebaseSharedSwift (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseSessions (10.18.0):
- FirebaseRemoteConfigInterop (10.29.0)
- FirebaseSessions (10.29.0):
- FirebaseCore (~> 10.5)
- FirebaseCoreExtension (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.10)
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleUtilities/Environment (~> 7.13)
- GoogleUtilities/UserDefaults (~> 7.13)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (10.18.0)
- FirebaseSharedSwift (10.29.0)
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- GoogleAppMeasurement (10.18.0):
- GoogleAppMeasurement/AdIdSupport (= 10.18.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
@ -126,30 +126,40 @@ PODS:
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleDataTransport (9.3.0):
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.12.0):
- GoogleUtilities/AppDelegateSwizzler (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.12.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.12.0)
- GoogleUtilities/Logger (7.12.0):
- GoogleUtilities/ISASwizzler (7.13.3):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/MethodSwizzler (7.12.0):
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Network (7.12.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Network (7.13.3):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.12.0)"
- GoogleUtilities/Reachability (7.12.0):
- "GoogleUtilities/NSData+zlib (7.13.3)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/Reachability (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.12.0):
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- in_app_review (0.2.0):
- Flutter
- nanopb (2.30909.1):
@ -162,15 +172,15 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.3.1)
- PromisesSwift (2.3.1):
- PromisesObjC (= 2.3.1)
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
@ -192,7 +202,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
@ -209,9 +219,9 @@ SPEC REPOS:
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- FMDB
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
@ -247,7 +257,7 @@ EXTERNAL SOURCES:
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
:path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
@ -257,42 +267,42 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
Firebase: 414ad272f8d02dfbf12662a9d43f4bba9bec2a06
firebase_analytics: a8c0d8ba83f213c9b775b5f61d36dadaafb1b239
firebase_core: f802c5c1f6caff9b8d38b591a36e7b25f8878936
firebase_crashlytics: 0db78a5b6badc630f21a833d0c9ab20ab5d81948
firebase_performance: e24d96d0be9d67e1adc1a1fdff6e78c2c460b4e6
FirebaseABTesting: d8b10ff4c6d1a9d6b11f02a08463ad5fd9fc6b1b
firebase_analytics: b3d6dd14c61549a29abb10e9843446a4d8bb53eb
firebase_core: 0af4a2b24f62071f9bf283691c0ee41556dcb3f5
firebase_crashlytics: 4b91b8ad60ee7c168fe88979f84c9573a729de7a
firebase_performance: 2183122a3c7a650c80d8c164e9e28f13c4c62fc7
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: 4d310b35c48eaa4a058ddc04bdca6bdb5dc0fe80
FirebaseCore: 2322423314d92f946219c8791674d2f3345b598f
FirebaseCoreExtension: 62b201498aa10535801cdf3448c7f4db5e24ed80
FirebaseCoreInternal: 8eb002e564b533bdcf1ba011f33f2b5c10e2ed4a
FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseCrashlytics: 86d5bce01f42fa1db265f87ff1d591f04db610ec
FirebaseInstallations: e842042ec6ac1fd2e37d7706363ebe7f662afea4
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebasePerformance: c406a9198d8aabfbac281b42855f5122fc1bcf69
FirebaseRemoteConfig: bbd42790a4e84fde6aab7eae810b608e7b5c0bf6
FirebaseSessions: f90fe9212ee2818641eda051c0835c9c4e30d9ae
FirebaseSharedSwift: 62e248642c0582324d0390706cadd314687c116b
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
FirebaseRemoteConfig: 48ef3f243742a8d72422ccfc9f986e19d7de53fd
FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d
FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAppMeasurement: 70ce9aa438cff1cfb31ea3e660bcc67734cb716e
GoogleDataTransport: 57c22343ab29bc686febbf7cbb13bad167c2d8fe
GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
COCOAPODS: 1.13.0

View file

@ -159,7 +159,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@ -367,7 +367,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -448,7 +448,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -497,7 +497,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -1,13 +1,13 @@
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

View file

@ -48,13 +48,11 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View file

@ -1 +1 @@
2023 Tommy Chow
2024 Tommy Chow

View file

@ -1,3 +1,10 @@
New Features
• Added an option to un-darken historical recent messages in chat
Improvements
• [iPadOS] Enabled video quality settings on iPad (by @Shingyx on GitHub)
• Moved exact matches of searched categories to the top (by @Shingyx on GitHub)
Bug Fixes
• Fixed own display name not showing in sent chat messages
• Fixed rare case where 7TV emotes don't show in chat
• Fixed extraneous notices when toggling chat-only mode
• Fixed cookie banner blocking login and added backup workaround instructions in the login screen toolbar (instructions by @UmmonTeal on GitHub)

View file

@ -522,4 +522,20 @@ class TwitchApi {
return false;
}
}
// Unblocks the user with the given ID and returns true on success or false on failure.
Future<List<dynamic>> getRecentMessages({
required String userLogin,
}) async {
final url = Uri.parse(
'https://recent-messages.robotty.de/api/v2/recent-messages/$userLogin',
);
final response = await _client.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body)['messages'] as List;
} else {
return Future.error('Failed to get recent messages for $userLogin');
}
}
}

View file

@ -17,6 +17,7 @@ import 'package:frosty/screens/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:http/http.dart';
import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
@ -25,6 +26,19 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Pass all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
final prefs = await SharedPreferences.getInstance();
final firstRun = prefs.getBool('first_run') ?? true;
@ -38,6 +52,8 @@ void main() async {
await storage.deleteAll();
}
await initUtils();
// With the shared preferences instance, obtain the existing user settings if it exists.
// If default settings don't exist, use an empty JSON string to use the default values.
final userSettings = prefs.getString('settings') ?? '{}';
@ -48,19 +64,6 @@ void main() async {
// Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Pass all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
/// Initialize API services with a common client.
/// This will prevent every request from creating a new client instance.
final client = Client();

View file

@ -17,7 +17,7 @@ BadgeInfoTwitch _$BadgeInfoTwitchFromJson(Map<String, dynamic> json) =>
);
BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
json['id'] as int,
(json['id'] as num).toInt(),
json['title'] as String,
json['color'] as String,
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),

View file

@ -7,7 +7,7 @@ part of 'chatters.dart';
// **************************************************************************
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
json['chatter_count'] as int,
(json['chatter_count'] as num).toInt(),
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
);

View file

@ -108,6 +108,7 @@ class EmoteFFZ {
final int width;
final OwnerFFZ owner;
final ImagesFFZ urls;
final ImagesFFZ? animated;
const EmoteFFZ(
this.name,
@ -115,6 +116,7 @@ class EmoteFFZ {
this.width,
this.owner,
this.urls,
this.animated,
);
factory EmoteFFZ.fromJson(Map<String, dynamic> json) =>
@ -251,7 +253,12 @@ class Emote {
zeroWidth: false,
width: emote.width,
height: emote.height,
url: emote.urls.url4x ?? emote.urls.url2x ?? emote.urls.url1x,
url: emote.animated?.url4x ??
emote.animated?.url2x ??
emote.animated?.url1x ??
emote.urls.url4x ??
emote.urls.url2x ??
emote.urls.url1x,
type: type,
ownerDisplayName: emote.owner.displayName,
ownerUsername: emote.owner.name,

View file

@ -29,7 +29,7 @@ EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
);
RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
json['set'] as int,
(json['set'] as num).toInt(),
json['vip_badge'] == null
? null
: ImagesFFZ.fromJson(json['vip_badge'] as Map<String, dynamic>),
@ -51,10 +51,13 @@ OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['name'] as String,
json['height'] as int,
json['width'] as int,
(json['height'] as num).toInt(),
(json['width'] as num).toInt(),
OwnerFFZ.fromJson(json['owner'] as Map<String, dynamic>),
ImagesFFZ.fromJson(json['urls'] as Map<String, dynamic>),
json['animated'] == null
? null
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
);
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
@ -73,7 +76,7 @@ Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
json['id'] as String,
json['name'] as String,
json['flags'] as int,
(json['flags'] as num).toInt(),
json['owner'] == null
? null
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
@ -89,16 +92,16 @@ Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
json['name'] as String,
json['width'] as int,
json['height'] as int,
(json['width'] as num).toInt(),
(json['height'] as num).toInt(),
json['format'] as String,
);
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
name: json['name'] as String,
realName: json['realName'] as String?,
width: json['width'] as int?,
height: json['height'] as int?,
width: (json['width'] as num?)?.toInt(),
height: (json['height'] as num?)?.toInt(),
zeroWidth: json['zeroWidth'] as bool,
url: json['url'] as String,
type: $enumDecode(_$EmoteTypeEnumMap, json['type']),

View file

@ -7,8 +7,8 @@ part of 'events.dart';
// **************************************************************************
SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
op: json['op'] as int,
t: json['t'] as int?,
op: (json['op'] as num).toInt(),
t: (json['t'] as num?)?.toInt(),
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
);

View file

@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
@ -675,7 +676,7 @@ class IRCMessage {
Uri.parse(text),
mode: launchExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
: LaunchMode.inAppBrowserView,
),
);
} else {
@ -728,7 +729,13 @@ class IRCMessage {
primary: false,
children: [
ListTile(
leading: leading,
leading: InkWell(
onTap: () => showDialog(
context: context,
builder: (context) => FrostyPhotoViewDialog(imageUrl: url),
),
child: leading,
),
title: Text(
title,
style: const TextStyle(
@ -764,7 +771,7 @@ class IRCMessage {
Uri.parse(url),
mode: launchExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
: LaunchMode.inAppBrowserView,
);
Navigator.pop(context);
@ -823,13 +830,16 @@ class IRCMessage {
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
//
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
var message = splitMessage.length > 3
? splitMessage.sublist(3).join(' ').substring(1)
: null;
String? message;
if (splitMessage.length > 3) {
final combinedMessage = splitMessage.sublist(3).join(' ');
if (combinedMessage.startsWith(':')) {
message = combinedMessage.substring(1);
} else {
message = combinedMessage;
}
}
// Now process any Twitch emotes contained in the message tags.
// The map containing emotes from the user's tags to their URL.
@ -837,7 +847,7 @@ class IRCMessage {
final localEmotes = <String, Emote>{};
final emoteTags = mappedTags['emotes'];
if (emoteTags != null) {
if (emoteTags != null && message != null) {
// Emotes and their indices are separated by '/' so split them there.
final emotes = emoteTags.split('/');
@ -864,7 +874,7 @@ class IRCMessage {
final startIndex = int.parse(indexSplit[0]);
final endIndex = int.parse(indexSplit[1]);
final emoteWord = message!.substring(startIndex, endIndex + 1);
final emoteWord = message.substring(startIndex, endIndex + 1);
// Store the emote word and its associated URL for later use.
localEmotes[emoteWord] = Emote(
@ -895,6 +905,9 @@ class IRCMessage {
// Escape the message
message = message
.split(' ')
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.where((element) => element != '')
.join(' ');

View file

@ -13,7 +13,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
json['game_id'] as String,
json['game_name'] as String,
json['title'] as String,
json['viewer_count'] as int,
(json['viewer_count'] as num).toInt(),
json['started_at'] as String,
json['thumbnail_url'] as String,
);

View file

@ -47,6 +47,7 @@ class _VideoChatState extends State<VideoChat> {
final _chatKey = GlobalKey();
late final ChatStore _chatStore = ChatStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
channelId: widget.userId,
displayName: widget.userName,

View file

@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -98,7 +97,7 @@ class Chat extends StatelessWidget {
ChatBottomBar(chatStore: chatStore),
PopScope(
canPop: Platform.isIOS,
onPopInvoked: (didPop) {
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If pressing the back button on Android while the emote menu
@ -129,26 +128,31 @@ class Chat extends StatelessWidget {
const Divider(),
Expanded(
child: FrostyPageView(
headers: const [
headers: [
'Recent',
'Twitch',
'7TV',
'BTTV',
'FFZ',
if (chatStore.settings.showTwitchEmotes)
'Twitch',
if (chatStore.settings.show7TVEmotes) '7TV',
if (chatStore.settings.showBTTVEmotes) 'BTTV',
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
children: [
RecentEmotesPanel(
chatStore: chatStore,
),
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore.userEmoteSectionToEmotes,
),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore.userEmoteSectionToEmotes,
),
...[
chatStore.assetsStore.sevenTVEmotes,
chatStore.assetsStore.bttvEmotes,
chatStore.assetsStore.ffzEmotes,
if (chatStore.settings.show7TVEmotes)
chatStore.assetsStore.sevenTVEmotes,
if (chatStore.settings.showBTTVEmotes)
chatStore.assetsStore.bttvEmotes,
if (chatStore.settings.showFFZEmotes)
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,

View file

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/main.dart';
@ -214,6 +212,18 @@ class ChatDetails extends StatelessWidget {
chatStore.updateNotification('Badges and emotes refreshed');
},
),
ListTile(
leading: const Icon(Icons.chat_rounded),
title: Observer(
builder: (context) {
return Text(
'${chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode',
);
},
),
onTap: () =>
chatStore.settings.showVideo = !chatStore.settings.showVideo,
),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Settings'),

View file

@ -136,7 +136,7 @@ class _ChattersListState extends State<ChattersList> {
.elementAt(index),
);
if (!mounted) return;
if (!context.mounted) return;
showModalBottomSheet(
isScrollControlled: true,

View file

@ -125,91 +125,87 @@ abstract class ChatAssetsStoreBase with Store {
required Map<String, String> headers,
required Function onEmoteError,
required Function onBadgeError,
bool showTwitchEmotes = true,
bool showTwitchBadges = true,
bool show7TVEmotes = true,
bool showBTTVEmotes = true,
bool showBTTVBadges = true,
bool showFFZEmotes = true,
bool showFFZBadges = true,
}) =>
// Fetch the global and channel's assets (emotes & badges).
// Async awaits are placed in a list so they are performed in parallel.
//
// Emotes
Future.wait([
emotesFuture(
channelId: channelId,
headers: headers,
onError: onEmoteError,
),
badgesFuture(
channelId: channelId,
headers: headers,
onError: onBadgeError,
),
]);
Future.wait([
if (showTwitchEmotes) ...[
twitchApi
.getEmotesGlobal(headers: headers)
.catchError(onEmoteError),
twitchApi
.getEmotesChannel(id: channelId, headers: headers)
.then((emotes) {
_userEmoteSectionToEmotes.update(
'Channel Emotes',
(existingEmoteSet) => [...existingEmoteSet, ...emotes],
ifAbsent: () => emotes.toList(),
);
@action
Future<void> emotesFuture({
required String channelId,
required Map<String, String> headers,
required Function onError,
}) =>
// Parallel futures look ugly, but are worth the performance improvement.
// Each future has it's own catchError so that the entire future is not ended on an error.
// This will ensure that other futures complete even if one fails.
Future.wait([
ffzApi.getEmotesGlobal().catchError(onError),
bttvApi.getEmotesGlobal().catchError(onError),
bttvApi.getEmotesChannel(id: channelId).catchError(onError),
twitchApi.getEmotesGlobal(headers: headers).catchError(onError),
twitchApi
.getEmotesChannel(id: channelId, headers: headers)
.then((emotes) {
_userEmoteSectionToEmotes.update(
'Channel Emotes',
(existingEmoteSet) => [...existingEmoteSet, ...emotes],
ifAbsent: () => emotes.toList(),
);
return emotes;
}).catchError(onEmoteError),
],
if (show7TVEmotes) ...[
sevenTVApi.getEmotesGlobal().catchError(onEmoteError),
sevenTVApi.getEmotesChannel(id: channelId).then((data) {
final (setId, emotes) = data;
sevenTvEmoteSetId = setId;
return emotes;
}).catchError(onEmoteError),
],
if (showBTTVEmotes) ...[
bttvApi.getEmotesGlobal().catchError(onEmoteError),
bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError),
],
if (showFFZEmotes) ...[
ffzApi.getEmotesGlobal().catchError(onEmoteError),
ffzApi.getRoomInfo(id: channelId).then((ffzRoom) {
final (roomInfo, emotes) = ffzRoom;
return emotes;
}).catchError(onError),
sevenTVApi.getEmotesGlobal().catchError(onError),
sevenTVApi.getEmotesChannel(id: channelId).then((data) {
final (setId, emotes) = data;
sevenTvEmoteSetId = setId;
return emotes;
}).catchError(onError),
ffzApi.getRoomInfo(id: channelId).then((ffzRoom) {
final (roomInfo, emotes) = ffzRoom;
ffzRoomInfo = roomInfo;
return emotes;
}).catchError(onError),
]).then((assets) => assets.expand((list) => list)).then(
(emotes) => _emoteToObject = {
for (final emote in emotes) emote.name: emote,
}.asObservable(),
);
@action
Future<void> badgesFuture({
required String channelId,
required Map<String, String> headers,
required Function onError,
}) =>
Future.wait([
// Get global badges first, then channel badges to avoid badge conflicts.
// We want the channel badges to override the global badges.
twitchApi
.getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then(
(_) => twitchApi
.getBadgesChannel(id: channelId, headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.catchError(onError),
ffzRoomInfo = roomInfo;
return emotes;
}).catchError(onEmoteError),
],
]).then((assets) => assets.expand((list) => list)).then(
(emotes) => _emoteToObject = {
for (final emote in emotes) emote.name: emote,
}.asObservable(),
),
ffzApi
.getBadges()
.then((badges) => _userToFFZBadges = badges)
.catchError(onError),
bttvApi
.getBadges()
.then((badges) => _userToBTTVBadges = badges)
.catchError(onError),
//Emotes
Future.wait([
// Get global badges first, then channel badges to avoid badge conflicts.
// We want the channel badges to override the global badges.
if (showTwitchBadges)
twitchApi
.getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then(
(_) => twitchApi
.getBadgesChannel(id: channelId, headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.catchError(onBadgeError),
),
if (showFFZBadges)
ffzApi
.getBadges()
.then((badges) => _userToFFZBadges = badges)
.catchError(onBadgeError),
if (showBTTVBadges)
bttvApi
.getBadges()
.then((badges) => _userToBTTVBadges = badges)
.catchError(onBadgeError),
]),
]);
@action

View file

@ -203,7 +203,14 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
{required String channelId,
required Map<String, String> headers,
required Function onEmoteError,
required Function onBadgeError}) {
required Function onBadgeError,
bool showTwitchEmotes = true,
bool showTwitchBadges = true,
bool show7TVEmotes = true,
bool showBTTVEmotes = true,
bool showBTTVBadges = true,
bool showFFZEmotes = true,
bool showFFZBadges = true}) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.assetsFuture');
try {
@ -211,37 +218,14 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
channelId: channelId,
headers: headers,
onEmoteError: onEmoteError,
onBadgeError: onBadgeError);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
Future<void> emotesFuture(
{required String channelId,
required Map<String, String> headers,
required Function onError}) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.emotesFuture');
try {
return super.emotesFuture(
channelId: channelId, headers: headers, onError: onError);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
Future<void> badgesFuture(
{required String channelId,
required Map<String, String> headers,
required Function onError}) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.badgesFuture');
try {
return super.badgesFuture(
channelId: channelId, headers: headers, onError: onError);
onBadgeError: onBadgeError,
showTwitchEmotes: showTwitchEmotes,
showTwitchBadges: showTwitchBadges,
show7TVEmotes: show7TVEmotes,
showBTTVEmotes: showBTTVEmotes,
showBTTVBadges: showBTTVBadges,
showFFZEmotes: showFFZEmotes,
showFFZBadges: showFFZBadges);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.dart';
@ -26,6 +27,8 @@ abstract class ChatStoreBase with Store {
/// The maximum ammount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
final TwitchApi twitchApi;
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
final _messagesToRemove = (_messageLimit * 0.2).toInt();
@ -47,6 +50,8 @@ abstract class ChatStoreBase with Store {
/// The channel's display name to show on widgets.
final String displayName;
var _shouldDisconnect = false;
/// The Twitch IRC WebSocket channel.
WebSocketChannel? _channel;
@ -55,8 +60,6 @@ abstract class ChatStoreBase with Store {
WebSocketChannel? _sevenTVChannel;
StreamSubscription? _sevenTVChannelListener;
static const _maxRetries = 5;
// The retry counter for exponential backoff.
@ -84,7 +87,7 @@ abstract class ChatStoreBase with Store {
final reactions = <ReactionDisposer>[];
/// The periodic timer used for batching chat message re-renders.
late final Timer _messageBufferTimer;
Timer? _messageBufferTimer;
/// The list of chat messages to add once autoscroll is resumed.
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
@ -150,6 +153,7 @@ abstract class ChatStoreBase with Store {
IRCMessage? replyingToMessage;
ChatStoreBase({
required this.twitchApi,
required this.auth,
required this.chatDetailsStore,
required this.assetsStore,
@ -166,23 +170,44 @@ abstract class ChatStoreBase with Store {
reactions.add(
reaction(
(_) => auth.isLoggedIn,
(_) => _channel?.sink.close(1001),
(_) => _channel?.sink.close(1000),
),
);
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) => addMessages(),
reactions.add(
reaction(
(_) => [
settings.showTwitchEmotes,
settings.showTwitchBadges,
settings.show7TVEmotes,
settings.showBTTVEmotes,
settings.showBTTVBadges,
settings.showFFZEmotes,
settings.showFFZBadges,
],
(_) {
if (!settings.show7TVEmotes) {
_sevenTVChannel?.sink.close(1000);
}
getAssets().then((_) {
if (settings.show7TVEmotes) {
final emoteSetId = assetsStore.sevenTvEmoteSetId;
if (emoteSetId != null) {
listenToSevenTVEmoteSet(emoteSetId: emoteSetId);
}
}
});
},
),
);
assetsStore.init();
messageBuffer
.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.chatDelay > 0) {
messageBuffer.add(
if (settings.showVideo && settings.chatDelay > 0) {
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
@ -190,7 +215,35 @@ abstract class ChatStoreBase with Store {
);
}
connectToChat();
reactions.add(
reaction(
(_) => settings.showVideo,
(showVideo) {
if (settings.chatDelay > 0) {
if (showVideo) {
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
),
);
} else {
_messages.add(
IRCMessage.createNotice(
message: 'Removing message delay...',
),
);
}
}
},
),
);
if (settings.showRecentMessages) {
getRecentMessage().then((_) => connectToChat());
} else {
connectToChat();
}
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() {
@ -329,13 +382,23 @@ abstract class ChatStoreBase with Store {
messageBuffer.add(
IRCMessage.createNotice(
message:
"Connected to ${getReadableName(displayName, channelName)}'s chat!",
"Welcome to ${getReadableName(displayName, channelName)}'s chat!",
),
);
// Activate the message buffer.
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) => addMessages(),
);
getAssets().then((_) {
if (assetsStore.sevenTvEmoteSetId != null) {
listenToSevenTVEmoteSet(emoteSetId: assetsStore.sevenTvEmoteSetId!);
if (!settings.show7TVEmotes) return;
final emoteSetId = assetsStore.sevenTvEmoteSetId;
if (emoteSetId != null && !_shouldDisconnect) {
listenToSevenTVEmoteSet(emoteSetId: emoteSetId);
}
});
@ -359,6 +422,13 @@ abstract class ChatStoreBase with Store {
debugPrint(error.toString());
return <Badge>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showBTTVBadges: settings.showBTTVBadges,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
/// Re-enables [_autoScroll] and jumps to the latest message.
@ -385,56 +455,65 @@ abstract class ChatStoreBase with Store {
),
);
_sevenTVChannel?.sink.close(1001);
_sevenTVChannel?.sink.close(1000);
_sevenTVChannel =
WebSocketChannel.connect(Uri.parse('wss://events.7tv.io/v3'));
_sevenTVChannelListener = _sevenTVChannel?.stream.listen(
(data) => Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() {
debugPrint(data);
final decoded = jsonDecode(data);
void listener(dynamic data) {
// debugPrint(data);
final decoded = jsonDecode(data);
final event = SevenTVEvent.fromJson(decoded);
final event = SevenTVEvent.fromJson(decoded);
final body = event.d.body;
if (event.d.type != 'emote_set.update' || body == null) return;
final body = event.d.body;
if (event.d.type != 'emote_set.update' || body == null) return;
if (body.pushed != null) {
final pushedEmote = body.pushed?.first.value;
if (body.pushed != null) {
final pushedEmote = body.pushed?.first.value;
if (pushedEmote == null) return;
if (pushedEmote == null) return;
final emote = Emote.from7TV(pushedEmote, EmoteType.sevenTVChannel);
final emote = Emote.from7TV(pushedEmote, EmoteType.sevenTVChannel);
assetsStore.emoteToObject[emote.name] = emote;
assetsStore.emoteToObject[emote.name] = emote;
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} added 7TV emote "${emote.name}" to chat',
),
);
} else if (body.pulled != null) {
final pulledEmote = body.pulled?.first.oldValue;
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} added 7TV emote "${emote.name}" to chat',
),
);
} else if (body.pulled != null) {
final pulledEmote = body.pulled?.first.oldValue;
if (pulledEmote == null) return;
if (pulledEmote == null) return;
assetsStore.emoteToObject.removeWhere(
(name, _) => name == pulledEmote.name,
);
assetsStore.emoteToObject.removeWhere(
(name, _) => name == pulledEmote.name,
);
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} removed 7TV emote "${pulledEmote.name}" from chat',
),
);
}
},
),
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} removed 7TV emote "${pulledEmote.name}" from chat',
),
);
}
}
_sevenTVChannel?.stream.listen(
(data) {
if (!settings.showVideo || settings.chatDelay == 0) {
listener(data);
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => listener(data),
);
}
},
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
onDone: () => debugPrint('7TV events done'),
);
_sevenTVChannel?.sink.add(jsonEncode(subscribePayload));
@ -442,19 +521,28 @@ abstract class ChatStoreBase with Store {
@action
void connectToChat() {
_channel?.sink.close(1001);
_channel?.sink.close(1000);
_channel =
WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
(data) => Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
),
(data) {
if (!settings.showVideo || settings.chatDelay == 0) {
_handleIRCData(data.toString());
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
);
}
},
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
if (_channel == null) return;
if (_shouldDisconnect) {
_sevenTVChannel?.sink.close(1000);
return;
}
if (_retries >= _maxRetries) {
messageBuffer.add(
@ -655,19 +743,25 @@ abstract class ChatStoreBase with Store {
timeRemaining = const Duration();
}
@action
Future<void> getRecentMessage() async {
final recentMessages =
await twitchApi.getRecentMessages(userLogin: channelName);
for (final message in recentMessages) {
_handleIRCData(message);
}
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_messageBufferTimer.cancel();
_shouldDisconnect = true;
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
sleepTimer?.cancel();
_sevenTVChannel?.sink.close(1001);
_sevenTVChannel = null;
_sevenTVChannelListener?.cancel();
_channel?.sink.close(1001);
_channel = null;
_channelListener?.cancel();
_channel?.sink.close(1000);
for (final reactionDisposer in reactions) {
reactionDisposer();

View file

@ -219,6 +219,14 @@ mixin _$ChatStore on ChatStoreBase, Store {
return _$getAssetsAsyncAction.run(() => super.getAssets());
}
late final _$getRecentMessageAsyncAction =
AsyncAction('ChatStoreBase.getRecentMessage', context: context);
@override
Future<void> getRecentMessage() {
return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage());
}
late final _$ChatStoreBaseActionController =
ActionController(name: 'ChatStoreBase', context: context);

View file

@ -14,25 +14,32 @@ class ChatBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final emoteMenuButton = Tooltip(
message: 'Emote menu',
preferBelow: false,
child: IconButton(
color: chatStore.assetsStore.showEmoteMenu
? Theme.of(context).colorScheme.secondary
: null,
icon: Icon(
chatStore.assetsStore.showEmoteMenu
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined,
),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu =
!chatStore.assetsStore.showEmoteMenu;
},
),
);
final isEmotesEnabled = chatStore.settings.showTwitchEmotes ||
chatStore.settings.show7TVEmotes ||
chatStore.settings.showBTTVEmotes ||
chatStore.settings.showFFZEmotes;
final emoteMenuButton = isEmotesEnabled
? Tooltip(
message: 'Emote menu',
preferBelow: false,
child: IconButton(
color: chatStore.assetsStore.showEmoteMenu
? Theme.of(context).colorScheme.secondary
: null,
icon: Icon(
chatStore.assetsStore.showEmoteMenu
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined,
),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu =
!chatStore.assetsStore.showEmoteMenu;
},
),
)
: null;
return Observer(
builder: (context) {
@ -172,79 +179,73 @@ class ChatBottomBar extends StatelessWidget {
),
],
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 0, 12),
child: Column(
padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8),
child: Row(
children: [
Row(
children: [
if (!chatStore.expandChat &&
chatStore.settings.chatWidth < 0.3 &&
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation ==
Orientation.landscape)
IconButton(
tooltip: 'Enter a message',
onPressed: () {
chatStore.expandChat = true;
chatStore.textFieldFocusNode.requestFocus();
},
icon: const Icon(Icons.edit),
)
else
Expanded(
child: TextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
prefixIcon:
chatStore.settings.emoteMenuButtonOnLeft
? emoteMenuButton
: null,
suffixIcon:
chatStore.settings.emoteMenuButtonOnLeft
? null
: emoteMenuButton,
hintMaxLines: 1,
hintText: chatStore.auth.isLoggedIn
? 'Send a ${chatStore.replyingToMessage != null ? 'reply' : 'message'} ${chatStore.settings.chatDelay == 0 ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}'
: 'Log in to chat',
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
),
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
MediaQuery.of(context).orientation ==
Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send_rounded),
onPressed: chatStore.auth.isLoggedIn
? () => chatStore
.sendMessage(chatStore.textController.text)
if (!chatStore.expandChat &&
chatStore.settings.chatWidth < 0.3 &&
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation ==
Orientation.landscape)
IconButton(
tooltip: 'Enter a message',
onPressed: () {
chatStore.expandChat = true;
chatStore.textFieldFocusNode.requestFocus();
},
icon: const Icon(Icons.edit),
)
else
Expanded(
child: TextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
? emoteMenuButton
: null,
)
else
IconButton(
icon: Icon(Icons.adaptive.more_rounded),
tooltip: 'More',
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (_) => ChatDetails(
chatDetailsStore: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
),
),
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft
? null
: emoteMenuButton,
hintMaxLines: 1,
hintText: chatStore.auth.isLoggedIn
? 'Send a ${chatStore.replyingToMessage != null ? 'reply' : 'message'} ${chatStore.settings.chatDelay == 0 || !chatStore.settings.showVideo ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}'
: 'Log in to chat',
),
],
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
),
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
MediaQuery.of(context).orientation ==
Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send_rounded),
onPressed: chatStore.auth.isLoggedIn
? () => chatStore
.sendMessage(chatStore.textController.text)
: null,
)
else
IconButton(
icon: Icon(Icons.adaptive.more_rounded),
tooltip: 'More',
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (_) => ChatDetails(
chatDetailsStore: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
),
),
),
],
),
),

View file

@ -55,16 +55,18 @@ class ChatMessage extends StatelessWidget {
userLogin: nickname,
)
.then((user) {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: user.login,
userId: user.id,
displayName: user.displayName,
),
);
if (context.mounted) {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: user.login,
userId: user.id,
displayName: user.displayName,
),
);
}
});
}
@ -178,10 +180,9 @@ class ChatMessage extends StatelessWidget {
const mentionBorderRadius = BorderRadius.all(Radius.circular(4));
if (replyUser != null && replyBody != null) {
messageHeaderIcon = Icon(
Icons.reply_rounded,
Icons.chat_rounded,
size: messageHeaderIconSize,
color: messageHeaderTextColor,
textDirection: TextDirection.rtl,
);
messageHeader = GestureDetector(
onTap: isModal
@ -476,7 +477,7 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
return InkWell(
final finalMessage = InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
@ -486,6 +487,17 @@ class ChatMessage extends StatelessWidget {
onLongPress: () => onLongPressMessage(context),
child: coloredMessage,
);
final isHistorical = ircMessage.tags['historical'] == '1';
if (chatStore.settings.darkenRecentMessages && isHistorical) {
return Opacity(
opacity: 0.5,
child: finalMessage,
);
}
return finalMessage;
},
);
}

View file

@ -8,6 +8,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
@ -104,10 +105,11 @@ class VideoOverlay extends StatelessWidget {
children: [
Observer(
builder: (context) => Text(
videoStore.latency ?? '',
videoStore.latency ?? 'N/A',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
@ -115,7 +117,8 @@ class VideoOverlay extends StatelessWidget {
width: 8,
),
const Icon(
Icons.speed,
Icons.speed_rounded,
color: Colors.white,
),
],
),
@ -177,37 +180,39 @@ class VideoOverlay extends StatelessWidget {
),
);
final streamInfo = videoStore.streamInfo;
if (streamInfo == null) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
chatOverlayButton,
],
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
refreshButton,
if (!videoStore.isIPad) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),
),
],
);
}
return Observer(
builder: (context) {
final streamInfo = videoStore.streamInfo;
if (streamInfo == null) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
chatOverlayButton,
],
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
refreshButton,
// On iPad, hide the rotate button on the overlay
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (!isIPad()) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),
),
],
);
}
return Stack(
children: [
Row(
@ -227,7 +232,7 @@ class VideoOverlay extends StatelessWidget {
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
chatOverlayButton,
if (!Platform.isIOS) videoSettingsButton,
if (!Platform.isIOS || isIPad()) videoSettingsButton,
],
),
Center(
@ -249,11 +254,10 @@ class VideoOverlay extends StatelessWidget {
Align(
alignment: Alignment.bottomLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Tooltip(
@ -314,6 +318,9 @@ class VideoOverlay extends StatelessWidget {
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
],
@ -339,7 +346,9 @@ class VideoOverlay extends StatelessWidget {
),
),
refreshButton,
if (!videoStore.isIPad) rotateButton,
// On iPad, hide the rotate button on the overlay
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (!isIPad()) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
@ -45,7 +44,17 @@ abstract class VideoStoreBase with Store {
..addJavaScriptChannel(
'Latency',
onMessageReceived: (message) {
_latency = message.message;
final receivedLatency = message.message;
_latency = receivedLatency;
if (!settingsStore.autoSyncChatDelay) return;
final trimmedLatency = receivedLatency.split(' ')[0];
final latencyAsDouble = double.tryParse(trimmedLatency);
if (latencyAsDouble != null) {
settingsStore.chatDelay = latencyAsDouble;
}
},
)
..addJavaScriptChannel(
@ -68,14 +77,7 @@ abstract class VideoStoreBase with Store {
onMessageReceived: (message) async {
if (settingsStore.defaultToHighestQuality &&
_firstTimeSettingQuality) {
await updateStreamQualities();
await setStreamQuality(
_availableStreamQualities.firstWhere(
(quality) => quality != 'Auto',
orElse: () => 'Auto',
),
);
await _setStreamQualityIndex(1);
_firstTimeSettingQuality = false;
} else {
@ -115,10 +117,6 @@ abstract class VideoStoreBase with Store {
@readonly
var _overlayVisible = true;
/// If the current device is iPad.
@readonly
var _isIPad = false;
/// The current stream info, used for displaying relevant info on the overlay.
@readonly
StreamTwitch? _streamInfo;
@ -126,9 +124,13 @@ abstract class VideoStoreBase with Store {
@readonly
List<String> _availableStreamQualities = [];
// The current stream quality string
// The current stream quality index
@readonly
String _streamQuality = 'Auto';
int _streamQualityIndex = 0;
// The current stream quality string
String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
@readonly
String? _latency;
@ -190,12 +192,27 @@ abstract class VideoStoreBase with Store {
try {
await videoWebViewController.runJavaScript('''
{
document.querySelector('[data-a-target="player-settings-button"]').click();
document.querySelector('[data-a-target="player-settings-menu-item-quality"]').click();
const qualities = [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] label div')].map((el) => el.textContent);
document.querySelector('.tw-drop-down-menu-item-figure').click();
document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click();
StreamQualities.postMessage(JSON.stringify(qualities));
const asyncQuerySelector = (selector) => new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
(async () => {
(await asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click();
await asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] label div');
const qualities = [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] label div')].map((el) => el.textContent);
StreamQualities.postMessage(JSON.stringify(qualities));
(await asyncQuerySelector('.tw-drop-down-menu-item-figure')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button')).click();
})();
}
''');
} catch (e) {
@ -207,60 +224,106 @@ abstract class VideoStoreBase with Store {
Future<void> setStreamQuality(String newStreamQuality) async {
final indexOfStreamQuality =
_availableStreamQualities.indexOf(newStreamQuality);
await videoWebViewController.runJavaScript('''
await _setStreamQualityIndex(indexOfStreamQuality);
}
@action
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) async {
try {
await videoWebViewController.runJavaScript('''
{
document.querySelector('[data-a-target="player-settings-button"]').click();
document.querySelector('[data-a-target="player-settings-menu-item-quality"]').click();
[...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$indexOfStreamQuality].click();
document.querySelector('.tw-drop-down-menu-item-figure').click();
document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click();
const asyncQuerySelector = (selector) => new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
(async () => {
(await asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click();
await asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] input');
[...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$newStreamQualityIndex].click();
(await asyncQuerySelector('.tw-drop-down-menu-item-figure')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button')).click();
})();
}
''');
_streamQuality = newStreamQuality;
_streamQualityIndex = newStreamQualityIndex;
} catch (e) {
debugPrint(e.toString());
}
}
void _hideDefaultOverlay() {
videoWebViewController.runJavaScript('''
{
const hideElements = (...el) => {
el.forEach((el) => {
el?.style.setProperty("display", "none", "important");
})
Future<void> _hideDefaultOverlay() async {
try {
await videoWebViewController.runJavaScript('''
{
const hideElements = (...el) => {
el.forEach((el) => {
el?.style.setProperty("display", "none", "important");
})
}
const hide = () => {
const topBar = document.querySelector(".top-bar");
const playerControls = document.querySelector(".player-controls");
const channelDisclosures = document.querySelector("#channel-player-disclosures");
hideElements(topBar, playerControls, channelDisclosures);
}
const observer = new MutationObserver(() => {
const videoOverlay = document.querySelector('.video-player__overlay');
if(!videoOverlay) return;
hide();
const videoOverlayObserver = new MutationObserver(hide);
videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
}
const hide = () => {
const topBar = document.querySelector(".top-bar");
const playerControls = document.querySelector(".player-controls");
const channelDisclosures = document.querySelector("#channel-player-disclosures");
hideElements(topBar, playerControls, channelDisclosures);
}
const observer = new MutationObserver(() => {
const videoOverlay = document.querySelector('.video-player__overlay');
if(!videoOverlay) return;
hide();
const videoOverlayObserver = new MutationObserver(hide);
videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
}
''');
''');
} catch (e) {
debugPrint(e.toString());
}
}
void _listenOnLatencyChanges() {
videoWebViewController.runJavaScript('''
{
document.querySelector('[data-a-target="player-settings-button"]').click();
document.querySelector('[data-a-target="player-settings-menu-item-advanced"]').click();
document.querySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input').click();
document.querySelector('.tw-drop-down-menu-item-figure').click();
document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click();
document.querySelector('[data-a-target="player-overlay-video-stats"]').style.display = "none";
const observer = new MutationObserver((changes) => {
Latency.postMessage(changes[0].target.textContent);
})
observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true });
Future<void> _listenOnLatencyChanges() async {
try {
await videoWebViewController.runJavaScript('''
{
const asyncQuerySelector = (selector) => new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
''');
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
(async () => {
(await asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]')).click();
(await asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input')).click();
(await asyncQuerySelector('.tw-drop-down-menu-item-figure')).click();
(await asyncQuerySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button')).click();
(await asyncQuerySelector('[data-a-target="player-overlay-video-stats"]')).style.display = "none";
const observer = new MutationObserver((changes) => {
Latency.postMessage(changes[0].target.textContent);
})
observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true });
})();
}
''');
} catch (e) {
debugPrint(e.toString());
}
}
/// Initializes the video webview.
@ -270,38 +333,32 @@ abstract class VideoStoreBase with Store {
// Add event listeners to notify the JavaScript channels when the video plays and pauses.
try {
videoWebViewController.runJavaScript(
'''document.getElementsByTagName("video")[0].addEventListener("pause", () => {
'''
(function checkVideoElement() {
const videoElement = document.getElementsByTagName("video")[0];
if (videoElement) {
videoElement.addEventListener("pause", () => {
VideoPause.postMessage("video paused");
document.getElementsByTagName("video")[0].textTracks[0].mode = "hidden";
});''',
);
videoWebViewController.runJavaScript(
'''document.getElementsByTagName("video")[0].addEventListener("playing", () => {
VideoPlaying.postMessage("video playing")
document.getElementsByTagName("video")[0].textTracks[0].mode = "hidden";
});''',
videoElement.textTracks[0].mode = "hidden";
});
videoElement.addEventListener("playing", () => {
VideoPlaying.postMessage("video playing");
videoElement.textTracks[0].mode = "hidden";
});
} else {
setTimeout(checkVideoElement, 100); // Check again after 100ms
}
})();
''',
);
if (settingsStore.showOverlay) {
_hideDefaultOverlay();
_listenOnLatencyChanges();
await _hideDefaultOverlay();
await _listenOnLatencyChanges();
}
} catch (e) {
debugPrint(e.toString());
}
}
// Determine whether the device is an iPad or not.
// Used to show or hide the rotate button on the overlay.
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo;
if (info.model.toLowerCase().contains('ipad')) {
_isIPad = true;
} else {
_isIPad = false;
}
}
}
/// Called whenever the video/overlay is tapped.

View file

@ -45,24 +45,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_isIPadAtom =
Atom(name: 'VideoStoreBase._isIPad', context: context);
bool get isIPad {
_$_isIPadAtom.reportRead();
return super._isIPad;
}
@override
bool get _isIPad => isIPad;
@override
set _isIPad(bool value) {
_$_isIPadAtom.reportWrite(value, super._isIPad, () {
super._isIPad = value;
});
}
late final _$_streamInfoAtom =
Atom(name: 'VideoStoreBase._streamInfo', context: context);
@ -100,21 +82,21 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_streamQualityAtom =
Atom(name: 'VideoStoreBase._streamQuality', context: context);
late final _$_streamQualityIndexAtom =
Atom(name: 'VideoStoreBase._streamQualityIndex', context: context);
String get streamQuality {
_$_streamQualityAtom.reportRead();
return super._streamQuality;
int get streamQualityIndex {
_$_streamQualityIndexAtom.reportRead();
return super._streamQualityIndex;
}
@override
String get _streamQuality => streamQuality;
int get _streamQualityIndex => streamQualityIndex;
@override
set _streamQuality(String value) {
_$_streamQualityAtom.reportWrite(value, super._streamQuality, () {
super._streamQuality = value;
set _streamQualityIndex(int value) {
_$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () {
super._streamQualityIndex = value;
});
}
@ -154,6 +136,15 @@ mixin _$VideoStore on VideoStoreBase, Store {
.run(() => super.setStreamQuality(newStreamQuality));
}
late final _$_setStreamQualityIndexAsyncAction =
AsyncAction('VideoStoreBase._setStreamQualityIndex', context: context);
@override
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) {
return _$_setStreamQualityIndexAsyncAction
.run(() => super._setStreamQualityIndex(newStreamQualityIndex));
}
late final _$initVideoAsyncAction =
AsyncAction('VideoStoreBase.initVideo', context: context);

View file

@ -9,8 +9,11 @@ import 'package:frosty/screens/home/top/top.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/release_notes.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Home extends StatefulWidget {
const Home({super.key});
@ -24,6 +27,29 @@ class _HomeState extends State<Home> {
late final _homeStore = HomeStore(authStore: _authStore);
Future<void> checkAndShowReleaseNotes() async {
final packageInfo = await PackageInfo.fromPlatform();
final prefs = await SharedPreferences.getInstance();
final currentVersion = packageInfo.version;
final storedVersion = prefs.getString('last_shown_version');
if (storedVersion == null || storedVersion != currentVersion) {
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ReleaseNotes()),
).then((_) => prefs.setString('last_shown_version', currentVersion));
}
}
@override
void initState() {
super.initState();
checkAndShowReleaseNotes();
}
@override
Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([]);

View file

@ -31,7 +31,8 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
try {
final channelInfo = await widget.searchStore.searchChannel(search);
if (!mounted) return;
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(

View file

@ -83,6 +83,16 @@ abstract class SearchStoreBase with Store {
query: query,
headers: authStore.headersTwitch,
)
.then((categories) {
// Move exact matches to the first result
final matchingIndex = categories.data
.indexWhere((c) => c.name.toLowerCase() == query.toLowerCase());
if (matchingIndex >= 1) {
final matchingCategory = categories.data.removeAt(matchingIndex);
categories.data.insert(0, matchingCategory);
}
return categories;
})
.asObservable();
}

View file

@ -10,6 +10,7 @@ import 'package:frosty/utils.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
@ -72,7 +73,19 @@ class StreamCard extends StatelessWidget {
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
GestureDetector(
onLongPress: () => showDialog(
context: context,
builder: (context) => FrostyPhotoViewDialog(
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}',
'',
) +
cacheUrlExtension,
),
),
child: thumbnail,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
@ -173,6 +186,7 @@ class StreamCard extends StatelessWidget {
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withOpacity(0.8),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],

View file

@ -87,7 +87,8 @@ class _StreamsListState extends State<StreamsList>
),
);
if (!mounted) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},

View file

@ -65,7 +65,8 @@ class _CategoriesState extends State<Categories>
),
);
if (!mounted) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},

View file

@ -24,8 +24,31 @@ class OnboardingLogin extends StatelessWidget {
buttonIcon: const Icon(SimpleIcons.twitch),
skipRoute: const OnboardingSetup(),
route: Scaffold(
appBar: const FrostyAppBar(
title: Text('Connect with Twitch'),
appBar: FrostyAppBar(
title: const Text('Connect with Twitch'),
actions: [
IconButton(
icon: const Icon(Icons.help_rounded),
onPressed: () => showDialog(
context: context,
builder: (context) {
return AlertDialog(
title:
const Text('Workaround for the Twitch cookie banner'),
content: const Text(
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
);
},
),
),
],
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(

View file

@ -1,6 +1,7 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class OnboardingSetup extends StatelessWidget {
const OnboardingSetup({super.key});
@ -32,6 +34,37 @@ class OnboardingSetup extends StatelessWidget {
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(
TextSpan(
text:
'Loads historical recent messages in chat through a third-party API service at ',
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
),
),
],
),
),
value: settingsStore.showRecentMessages,
onChanged: (newValue) =>
settingsStore.showRecentMessages = newValue,
),
SettingsListSwitch(
title: 'Share crash logs and analytics',
subtitle: const Text(

View file

@ -64,8 +64,32 @@ class ProfileCard extends StatelessWidget {
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: const FrostyAppBar(
title: Text('Connect with Twitch'),
appBar: FrostyAppBar(
title: const Text('Connect with Twitch'),
actions: [
IconButton(
icon: const Icon(Icons.help_rounded),
onPressed: () => showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text(
'Workaround for the Twitch cookie banner',
),
content: const Text(
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, exit this page and then try again.',
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
);
},
),
),
],
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(),

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:url_launcher/url_launcher.dart';
class ChatSettings extends StatefulWidget {
final SettingsStore settingsStore;
@ -170,17 +172,23 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) => settingsStore.timestampType =
TimestampType.values[timestampNames.indexOf(newValue)],
),
const SectionHeader('Delay', showDivider: true),
SettingsListSlider(
title: 'Message delay',
trailing: '${settingsStore.chatDelay.toInt()}s',
subtitle:
'Adds a delay (in seconds) before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
value: settingsStore.chatDelay,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
const SectionHeader('Delay and latency', showDivider: true),
SettingsListSwitch(
title: 'Sync message delay with stream latency (experimental)',
value: settingsStore.autoSyncChatDelay,
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
),
if (!settingsStore.autoSyncChatDelay)
SettingsListSlider(
title: 'Message delay',
trailing: '${settingsStore.chatDelay.toInt()} seconds',
subtitle:
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
value: settingsStore.chatDelay,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
const SectionHeader('Alerts', showDivider: true),
SettingsListSwitch(
title: 'Highlight first time chatters',
@ -282,6 +290,83 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),
const SectionHeader('Emotes and badges', showDivider: true),
SettingsListSwitch(
title: 'Show Twitch emotes',
value: settingsStore.showTwitchEmotes,
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
),
SettingsListSwitch(
title: 'Show Twitch badges',
value: settingsStore.showTwitchBadges,
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
),
SettingsListSwitch(
title: 'Show 7TV emotes',
value: settingsStore.show7TVEmotes,
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
),
SettingsListSwitch(
title: 'Show BTTV emotes',
value: settingsStore.showBTTVEmotes,
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
),
SettingsListSwitch(
title: 'Show BTTV badges',
value: settingsStore.showBTTVBadges,
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
),
SettingsListSwitch(
title: 'Show FFZ emotes',
value: settingsStore.showFFZEmotes,
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
),
SettingsListSwitch(
title: 'Show FFZ badges',
value: settingsStore.showFFZBadges,
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
),
const SectionHeader('Recent messages', showDivider: true),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(
TextSpan(
text:
'Loads historical recent messages in chat through a third-party API service at ',
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
),
),
],
),
),
value: settingsStore.showRecentMessages,
onChanged: (newValue) =>
settingsStore.showRecentMessages = newValue,
),
SettingsListSwitch(
title: 'Darken historical recent messages',
subtitle: const Text(
'Makes historical recent messages 50% opacity to distinguish them from live messages.',
),
value: settingsStore.darkenRecentMessages,
onChanged: (newValue) =>
settingsStore.darkenRecentMessages = newValue,
),
],
),
);

View file

@ -7,6 +7,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/release_notes.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -70,7 +71,7 @@ class _OtherSettingsState extends State<OtherSettings> {
onTap: () async {
final packageInfo = await PackageInfo.fromPlatform();
if (!mounted) return;
if (!context.mounted) return;
showAboutDialog(
context: context,
@ -81,18 +82,17 @@ class _OtherSettingsState extends State<OtherSettings> {
applicationName: packageInfo.appName,
applicationVersion:
'Version ${packageInfo.version} (${packageInfo.buildNumber})',
applicationLegalese: '\u{a9} 2023 Tommy Chow',
applicationLegalese: '\u{a9} 2024 Tommy Chow',
);
},
),
ListTile(
leading: const Icon(Icons.launch_rounded),
title: const Text('Changelog'),
onTap: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
leading: const Icon(Icons.notes_rounded),
title: const Text('Release notes'),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ReleaseNotes(),
),
),
),
ListTile(
@ -102,7 +102,7 @@ class _OtherSettingsState extends State<OtherSettings> {
Uri.parse('https://www.frostyapp.io/#faq'),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
: LaunchMode.inAppBrowserView,
),
),
ListTile(
@ -113,7 +113,8 @@ class _OtherSettingsState extends State<OtherSettings> {
await DefaultCacheManager().emptyCache();
if (!mounted) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(

View file

@ -33,7 +33,7 @@ class Settings extends StatelessWidget {
Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
: LaunchMode.inAppBrowserView,
),
icon: const Icon(SimpleIcons.buymeacoffee),
),
@ -43,7 +43,7 @@ class Settings extends StatelessWidget {
Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
: LaunchMode.inAppBrowserView,
),
icon: const Icon(SimpleIcons.github),
),

View file

@ -84,12 +84,42 @@ abstract class AuthBase with Store {
}
WebViewController createAuthWebViewController({Widget? routeAfter}) {
return WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
final webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted);
return webViewController
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) =>
handleNavigation(request: request, routeAfter: routeAfter),
onPageFinished: (_) => webViewController.runJavaScript(
'''
{
function modifyElement(element) {
element.style.maxHeight = '20vh';
element.style.overflow = 'auto';
}
const observer = new MutationObserver((mutations) => {
for (let mutation of mutations) {
if (mutation.type === 'childList') {
const element = document.querySelector('.fAVISI');
if (element) {
modifyElement(element);
observer.disconnect();
break;
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
''',
),
),
)
..loadRequest(

View file

@ -116,6 +116,7 @@ abstract class _SettingsStoreBase with Store {
static const defaultTimestampType = TimestampType.disabled;
// Delay defaults
static const defaultAutoSyncChatDelay = false;
static const defaultChatDelay = 0.0;
// Alert defaults
@ -131,7 +132,7 @@ abstract class _SettingsStoreBase with Store {
static const defaultLandscapeChatLeftSide = false;
static const defaultLandscapeForceVerticalChat = false;
static const defaultLandscapeCutout = LandscapeCutoutType.none;
static const defaultChatWidth = 0.3;
static const defaultChatWidth = 0.25;
static const defaultFullScreenChatOverlayOpacity = 0.5;
// Sleep defaults
@ -140,6 +141,19 @@ abstract class _SettingsStoreBase with Store {
// Autocomplete defaults
static const defaultAutocomplete = true;
// Emotes and badges defaults
static const defaultShowTwitchEmotes = true;
static const defaultShowTwitchBadges = true;
static const defaultShow7TVEmotes = true;
static const defaultShowBTTVEmotes = true;
static const defaultShowBTTVBadges = true;
static const defaultShowFFZEmotes = true;
static const defaultShowFFZBadges = true;
// Recent messages defaults
static const defaultShowRecentMessages = false;
static const defaultDarkenRecentMessages = true;
// Message sizing options
@JsonKey(defaultValue: defaultBadgeScale)
@observable
@ -182,6 +196,10 @@ abstract class _SettingsStoreBase with Store {
var timestampType = defaultTimestampType;
// Delay options
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
@observable
var autoSyncChatDelay = defaultAutoSyncChatDelay;
@JsonKey(defaultValue: defaultChatDelay)
@observable
var chatDelay = defaultChatDelay;
@ -239,6 +257,44 @@ abstract class _SettingsStoreBase with Store {
@observable
var autocomplete = defaultAutocomplete;
// Emotes and badges
@JsonKey(defaultValue: defaultShowTwitchEmotes)
@observable
var showTwitchEmotes = defaultShowTwitchEmotes;
@JsonKey(defaultValue: defaultShowTwitchBadges)
@observable
var showTwitchBadges = defaultShowTwitchBadges;
@JsonKey(defaultValue: defaultShow7TVEmotes)
@observable
var show7TVEmotes = defaultShow7TVEmotes;
@JsonKey(defaultValue: defaultShowBTTVEmotes)
@observable
var showBTTVEmotes = defaultShowBTTVEmotes;
@JsonKey(defaultValue: defaultShowBTTVBadges)
@observable
var showBTTVBadges = defaultShowBTTVBadges;
@JsonKey(defaultValue: defaultShowFFZEmotes)
@observable
var showFFZEmotes = defaultShowFFZEmotes;
@JsonKey(defaultValue: defaultShowFFZBadges)
@observable
var showFFZBadges = defaultShowFFZBadges;
// Recent messages
@JsonKey(defaultValue: defaultShowRecentMessages)
@observable
var showRecentMessages = defaultShowRecentMessages;
@JsonKey(defaultValue: defaultDarkenRecentMessages)
@observable
var darkenRecentMessages = defaultDarkenRecentMessages;
@action
void resetChatSettings() {
badgeScale = defaultBadgeScale;
@ -252,6 +308,7 @@ abstract class _SettingsStoreBase with Store {
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
autoSyncChatDelay = defaultAutoSyncChatDelay;
chatDelay = defaultChatDelay;
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
@ -269,6 +326,17 @@ abstract class _SettingsStoreBase with Store {
chatOnlyPreventSleep = defaultChatOnlyPreventSleep;
autocomplete = defaultAutocomplete;
showTwitchEmotes = defaultShowTwitchEmotes;
showTwitchBadges = defaultShowTwitchBadges;
show7TVEmotes = defaultShow7TVEmotes;
showBTTVEmotes = defaultShowBTTVEmotes;
showBTTVBadges = defaultShowBTTVBadges;
showFFZEmotes = defaultShowFFZEmotes;
showFFZBadges = defaultShowFFZBadges;
showRecentMessages = defaultShowRecentMessages;
darkenRecentMessages = defaultDarkenRecentMessages;
}
// * Other settings

View file

@ -34,6 +34,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
_$TimestampTypeEnumMap, json['timestampType'],
unknownValue: TimestampType.disabled) ??
TimestampType.disabled
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..highlightFirstTimeChatter =
json['highlightFirstTimeChatter'] as bool? ?? true
@ -48,11 +49,20 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..landscapeCutout = $enumDecodeNullable(
_$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ??
LandscapeCutoutType.none
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.25
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? false
..autocomplete = json['autocomplete'] as bool? ?? true
..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true
..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true
..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true
..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
..darkenRecentMessages = json['darkenRecentMessages'] as bool? ?? true
..shareCrashLogsAndAnalytics =
json['shareCrashLogsAndAnalytics'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
@ -79,6 +89,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'showDeletedMessages': instance.showDeletedMessages,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
'autoSyncChatDelay': instance.autoSyncChatDelay,
'chatDelay': instance.chatDelay,
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
'showUserNotices': instance.showUserNotices,
@ -93,6 +104,15 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'chatOnlyPreventSleep': instance.chatOnlyPreventSleep,
'autocomplete': instance.autocomplete,
'showTwitchEmotes': instance.showTwitchEmotes,
'showTwitchBadges': instance.showTwitchBadges,
'show7TVEmotes': instance.show7TVEmotes,
'showBTTVEmotes': instance.showBTTVEmotes,
'showBTTVBadges': instance.showBTTVBadges,
'showFFZEmotes': instance.showFFZEmotes,
'showFFZBadges': instance.showFFZBadges,
'showRecentMessages': instance.showRecentMessages,
'darkenRecentMessages': instance.darkenRecentMessages,
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
'fullScreen': instance.fullScreen,
'fullScreenChatOverlay': instance.fullScreenChatOverlay,
@ -431,6 +451,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$autoSyncChatDelayAtom =
Atom(name: '_SettingsStoreBase.autoSyncChatDelay', context: context);
@override
bool get autoSyncChatDelay {
_$autoSyncChatDelayAtom.reportRead();
return super.autoSyncChatDelay;
}
@override
set autoSyncChatDelay(bool value) {
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
super.autoSyncChatDelay = value;
});
}
late final _$chatDelayAtom =
Atom(name: '_SettingsStoreBase.chatDelay', context: context);
@ -647,6 +683,151 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$showTwitchEmotesAtom =
Atom(name: '_SettingsStoreBase.showTwitchEmotes', context: context);
@override
bool get showTwitchEmotes {
_$showTwitchEmotesAtom.reportRead();
return super.showTwitchEmotes;
}
@override
set showTwitchEmotes(bool value) {
_$showTwitchEmotesAtom.reportWrite(value, super.showTwitchEmotes, () {
super.showTwitchEmotes = value;
});
}
late final _$showTwitchBadgesAtom =
Atom(name: '_SettingsStoreBase.showTwitchBadges', context: context);
@override
bool get showTwitchBadges {
_$showTwitchBadgesAtom.reportRead();
return super.showTwitchBadges;
}
@override
set showTwitchBadges(bool value) {
_$showTwitchBadgesAtom.reportWrite(value, super.showTwitchBadges, () {
super.showTwitchBadges = value;
});
}
late final _$show7TVEmotesAtom =
Atom(name: '_SettingsStoreBase.show7TVEmotes', context: context);
@override
bool get show7TVEmotes {
_$show7TVEmotesAtom.reportRead();
return super.show7TVEmotes;
}
@override
set show7TVEmotes(bool value) {
_$show7TVEmotesAtom.reportWrite(value, super.show7TVEmotes, () {
super.show7TVEmotes = value;
});
}
late final _$showBTTVEmotesAtom =
Atom(name: '_SettingsStoreBase.showBTTVEmotes', context: context);
@override
bool get showBTTVEmotes {
_$showBTTVEmotesAtom.reportRead();
return super.showBTTVEmotes;
}
@override
set showBTTVEmotes(bool value) {
_$showBTTVEmotesAtom.reportWrite(value, super.showBTTVEmotes, () {
super.showBTTVEmotes = value;
});
}
late final _$showBTTVBadgesAtom =
Atom(name: '_SettingsStoreBase.showBTTVBadges', context: context);
@override
bool get showBTTVBadges {
_$showBTTVBadgesAtom.reportRead();
return super.showBTTVBadges;
}
@override
set showBTTVBadges(bool value) {
_$showBTTVBadgesAtom.reportWrite(value, super.showBTTVBadges, () {
super.showBTTVBadges = value;
});
}
late final _$showFFZEmotesAtom =
Atom(name: '_SettingsStoreBase.showFFZEmotes', context: context);
@override
bool get showFFZEmotes {
_$showFFZEmotesAtom.reportRead();
return super.showFFZEmotes;
}
@override
set showFFZEmotes(bool value) {
_$showFFZEmotesAtom.reportWrite(value, super.showFFZEmotes, () {
super.showFFZEmotes = value;
});
}
late final _$showFFZBadgesAtom =
Atom(name: '_SettingsStoreBase.showFFZBadges', context: context);
@override
bool get showFFZBadges {
_$showFFZBadgesAtom.reportRead();
return super.showFFZBadges;
}
@override
set showFFZBadges(bool value) {
_$showFFZBadgesAtom.reportWrite(value, super.showFFZBadges, () {
super.showFFZBadges = value;
});
}
late final _$showRecentMessagesAtom =
Atom(name: '_SettingsStoreBase.showRecentMessages', context: context);
@override
bool get showRecentMessages {
_$showRecentMessagesAtom.reportRead();
return super.showRecentMessages;
}
@override
set showRecentMessages(bool value) {
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
super.showRecentMessages = value;
});
}
late final _$darkenRecentMessagesAtom =
Atom(name: '_SettingsStoreBase.darkenRecentMessages', context: context);
@override
bool get darkenRecentMessages {
_$darkenRecentMessagesAtom.reportRead();
return super.darkenRecentMessages;
}
@override
set darkenRecentMessages(bool value) {
_$darkenRecentMessagesAtom.reportWrite(value, super.darkenRecentMessages,
() {
super.darkenRecentMessages = value;
});
}
late final _$shareCrashLogsAndAnalyticsAtom = Atom(
name: '_SettingsStoreBase.shareCrashLogsAndAnalytics', context: context);
@ -788,6 +969,7 @@ useReadableColors: ${useReadableColors},
showDeletedMessages: ${showDeletedMessages},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
autoSyncChatDelay: ${autoSyncChatDelay},
chatDelay: ${chatDelay},
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
showUserNotices: ${showUserNotices},
@ -801,6 +983,15 @@ chatWidth: ${chatWidth},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
chatOnlyPreventSleep: ${chatOnlyPreventSleep},
autocomplete: ${autocomplete},
showTwitchEmotes: ${showTwitchEmotes},
showTwitchBadges: ${showTwitchBadges},
show7TVEmotes: ${show7TVEmotes},
showBTTVEmotes: ${showBTTVEmotes},
showBTTVBadges: ${showBTTVBadges},
showFFZEmotes: ${showFFZEmotes},
showFFZBadges: ${showFFZBadges},
showRecentMessages: ${showRecentMessages},
darkenRecentMessages: ${darkenRecentMessages},
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
fullScreen: ${fullScreen},
fullScreenChatOverlay: ${fullScreenChatOverlay}

View file

@ -5,6 +5,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/section_header.dart';
class VideoSettings extends StatelessWidget {
@ -23,7 +24,7 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
if (!Platform.isIOS)
if (!Platform.isIOS || isIPad())
SettingsListSwitch(
title: 'Default to highest quality',
value: settingsStore.defaultToHighestQuality,

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ReleaseNotes extends StatefulWidget {
const ReleaseNotes({super.key});
@override
State<ReleaseNotes> createState() => _ReleaseNotesState();
}
class _ReleaseNotesState extends State<ReleaseNotes> {
String releaseNotes = '';
@override
void initState() {
super.initState();
rootBundle.loadString('assets/release-notes.md').then(
(changelog) {
setState(() {
releaseNotes = changelog;
});
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Release notes'),
),
body: Markdown(
data: releaseNotes,
styleSheet: MarkdownStyleSheet(
h2: const TextStyle(fontSize: 20),
h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
h3Padding: const EdgeInsets.only(top: 16),
h4: const TextStyle(fontSize: 14),
h4Padding: const EdgeInsets.only(top: 16),
p: const TextStyle(fontSize: 14),
horizontalRuleDecoration: const BoxDecoration(
border: Border(
top: BorderSide(
color: Colors.transparent,
width: 32,
),
),
),
),
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(
href,
mode: context.read<SettingsStore>().launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
}
},
),
);
}
}

View file

@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';

View file

@ -1,3 +1,6 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:frosty/constants.dart';
String getReadableName(String displayName, String username) {
@ -7,3 +10,20 @@ String getReadableName(String displayName, String username) {
return displayName;
}
var _isIPad = false;
bool isIPad() {
return _isIPad;
}
Future<void> initUtils() async {
// Determine whether the device is an iPad or not.
if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo;
if (info.model.toLowerCase().contains('ipad')) {
_isIPad = true;
}
}
}

View file

@ -28,11 +28,15 @@ class BlockReportModal extends StatelessWidget {
leading: const Icon(Icons.block_rounded),
onTap: () => authStore
.showBlockDialog(
context,
targetUser: name,
targetUserId: userId,
)
.then((_) => Navigator.pop(context)),
context,
targetUser: name,
targetUserId: userId,
)
.then((_) {
if (context.mounted) {
Navigator.pop(context);
}
}),
title: Text('Block $name'),
),
ListTile(

View file

@ -0,0 +1,43 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class FrostyPhotoViewDialog extends StatefulWidget {
final String imageUrl;
const FrostyPhotoViewDialog({super.key, required this.imageUrl});
@override
State<FrostyPhotoViewDialog> createState() => _FrostyPhotoViewDialogState();
}
class _FrostyPhotoViewDialogState extends State<FrostyPhotoViewDialog> {
PhotoViewScaleState photoViewScaleState = PhotoViewScaleState.initial;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topCenter,
children: [
Dismissible(
key: const Key('photo_view_dismissible'),
direction: photoViewScaleState == PhotoViewScaleState.initial
? DismissDirection.vertical
: DismissDirection.none,
onDismissed: Navigator.of(context).pop,
child: PhotoView(
imageProvider: CachedNetworkImageProvider(widget.imageUrl),
scaleStateChangedCallback: (value) =>
setState(() => photoViewScaleState = value),
backgroundDecoration:
const BoxDecoration(color: Colors.transparent),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: Navigator.of(context).pop,
),
],
);
}
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';

File diff suppressed because it is too large Load diff

View file

@ -1,229 +1,244 @@
name: frosty
description: A Twitch client for iOS and Android.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 4.2.1+47
environment:
sdk: ">=3.0.0 <4.0.0"
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.6.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
http: ^1.1.0
web_socket_channel: ^2.1.0
flutter_secure_storage: ^9.0.0
intl: ^0.18.1
cached_network_image: ^3.1.0
webview_flutter: ^4.2.0
provider: ^6.0.1
mobx: ^2.0.7+4
flutter_mobx: ^2.0.0
shared_preferences: ^2.0.8
flutter_svg: ^2.0.5
url_launcher: ^6.0.17
package_info_plus: ^5.0.1
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^9.0.3
wakelock_plus: ^1.1.2
simple_icons: ^7.10.0
simple_pip_mode:
git:
url: https://github.com/tommyxchow/simple_pip_mode_flutter
firebase_core: ^2.10.0
firebase_crashlytics: ^3.1.2
firebase_performance: ^0.9.1+1
firebase_analytics: ^10.2.1
advanced_in_app_review: ^1.1.3
webview_flutter_android: ^3.7.0
webview_flutter_wkwebview: ^3.4.3
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^3.0.0
build_runner: ^2.1.1
json_serializable: ^6.0.0
mobx_codegen: ^2.0.6
flutter_native_splash: ^2.0.5
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Thin.ttf
- asset: assets/fonts/Inter-ExtraLight.ttf
- asset: assets/fonts/Inter-Light.ttf
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Medium.ttf
- asset: assets/fonts/Inter-SemiBold.ttf
- asset: assets/fonts/Inter-Bold.ttf
- asset: assets/fonts/Inter-ExtraBold.ttf
- asset: assets/fonts/Inter-Black.ttf
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/icons/logo.svg
- assets/fonts/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
# flutter pub run flutter_native_splash:create
# To restore Flutter's default white splash screen, run the following command in the terminal:
# flutter pub run flutter_native_splash:remove
# color or background_image is the only required parameter. Use color to set the background
# of your splash screen to a solid color. Use background_image to set the background of your
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
# color: "#000000"
background_image: "assets/icons/bg.png"
# Optional parameters are listed below. To enable a parameter, uncomment the line by removing
# the leading # character.
# The image parameter allows you to specify an image used in the splash screen. It must be a
# png file and should be sized for 4x pixel density.
image: assets/icons/logo.png
# This property allows you to specify an image used as branding in the splash screen. It must be
# a png file. Currently, it is only supported for Android and iOS.
# branding: assets/dart.png
# Specify your branding image for dark mode.
#branding_dark: assets/dart_dark.png
# To position the branding image at the bottom of the screen you can use bottom, bottomRight,
# and bottomLeft. The default values is bottom if not specified or specified something else.
#
# Make sure this content mode value should not be similar to android_gravity value and ios_content_mode
# value.
# branding_mode: bottom
# The color_dark, background_image_dark, and image_dark are parameters that set the background
# and image when the device is in dark mode. If they are not specified, the app will use the
# parameters from above. If the image_dark parameter is specified, color_dark or
# background_image_dark must be specified. color_dark and background_image_dark cannot both be
# set.
#color_dark: "#042a49"
#background_image_dark: "assets/dark-background.png"
#image_dark: assets/splash-invert.png
# The android, ios and web parameters can be used to disable generating a splash screen on a given
# platform.
#android: false
#ios: false
#web: false
# The position of the splash image can be set with android_gravity, ios_content_mode, and
# web_image_mode parameters. All default to center.
#
# android_gravity can be one of the following Android Gravity (see
# https://developer.android.com/reference/android/view/Gravity): bottom, center,
# center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal,
# fill_vertical, left, right, start, or top.
#android_gravity: center
#
# ios_content_mode can be one of the following iOS UIView.ContentMode (see
# https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill,
# scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight,
# bottomLeft, or bottomRight.
#ios_content_mode: center
#
# web_image_mode can be one of the following modes: center, contain, stretch, and cover.
#web_image_mode: center
# To hide the notification bar, use the fullscreen parameter. Has no affect in web since web
# has no notification bar. Defaults to false.
# NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads.
# To show the notification bar, add the following code to your Flutter app:
# WidgetsFlutterBinding.ensureInitialized();
# SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]);
#fullscreen: true
# If you have changed the name(s) of your info.plist file(s), you can specify the filename(s)
# with the info_plist_files parameter. Remove only the # characters in the three lines below,
# do not remove any spaces:
#info_plist_files:
# - 'ios/Runner/Info-Debug.plist'
# - 'ios/Runner/Info-Release.plist'
flutter_launcher_icons:
image_path_android: "assets/icons/icon_android.png"
image_path_ios: "assets/icons/icon_ios.png"
android: true
ios: true
remove_alpha_ios: true
name: frosty
description: A Twitch client for iOS and Android.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 4.5.0+51
environment:
sdk: ^3.5.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.6.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
http: ^1.1.0
web_socket_channel: ^3.0.0
flutter_secure_storage: ^9.0.0
intl: ^0.19.0
cached_network_image: ^3.1.0
webview_flutter: ^4.2.0
provider: ^6.0.1
mobx: ^2.0.7+4
flutter_mobx: ^2.0.0
shared_preferences: ^2.0.8
flutter_svg: ^2.0.5
url_launcher: ^6.0.17
package_info_plus: ^8.0.0
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^10.1.0
wakelock_plus: ^1.1.2
simple_icons: ^10.1.3
simple_pip_mode:
git:
url: https://github.com/tommyxchow/simple_pip_mode_flutter
firebase_core: ^2.10.0
firebase_crashlytics: ^3.1.2
firebase_performance: ^0.9.1+1
firebase_analytics: ^10.2.1
advanced_in_app_review: ^1.1.3
webview_flutter_android: ^3.7.0
webview_flutter_wkwebview: ^3.4.3
photo_view: ^0.15.0
flutter_markdown: ^0.7.3+1
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^4.0.0
build_runner: ^2.1.1
json_serializable: ^6.0.0
mobx_codegen: ^2.0.6
flutter_native_splash: ^2.0.5
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Thin.ttf
- asset: assets/fonts/Inter-ExtraLight.ttf
- asset: assets/fonts/Inter-Light.ttf
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Medium.ttf
- asset: assets/fonts/Inter-SemiBold.ttf
- asset: assets/fonts/Inter-Bold.ttf
- asset: assets/fonts/Inter-ExtraBold.ttf
- asset: assets/fonts/Inter-Black.ttf
- asset: assets/fonts/Inter-ThinItalic.ttf
- asset: assets/fonts/Inter-ExtraLightItalic.ttf
- asset: assets/fonts/Inter-LightItalic.ttf
- asset: assets/fonts/Inter-Italic.ttf
- asset: assets/fonts/Inter-MediumItalic.ttf
- asset: assets/fonts/Inter-SemiBoldItalic.ttf
- asset: assets/fonts/Inter-BoldItalic.ttf
- asset: assets/fonts/Inter-ExtraBoldItalic.ttf
- asset: assets/fonts/Inter-BlackItalic.ttf
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/icons/logo.svg
- assets/fonts/
- assets/release-notes.md
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
# flutter pub run flutter_native_splash:create
# To restore Flutter's default white splash screen, run the following command in the terminal:
# flutter pub run flutter_native_splash:remove
# color or background_image is the only required parameter. Use color to set the background
# of your splash screen to a solid color. Use background_image to set the background of your
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
# color: "#000000"
background_image: "assets/icons/bg.png"
# Optional parameters are listed below. To enable a parameter, uncomment the line by removing
# the leading # character.
# The image parameter allows you to specify an image used in the splash screen. It must be a
# png file and should be sized for 4x pixel density.
image: assets/icons/logo.png
# This property allows you to specify an image used as branding in the splash screen. It must be
# a png file. Currently, it is only supported for Android and iOS.
# branding: assets/dart.png
# Specify your branding image for dark mode.
#branding_dark: assets/dart_dark.png
# To position the branding image at the bottom of the screen you can use bottom, bottomRight,
# and bottomLeft. The default values is bottom if not specified or specified something else.
#
# Make sure this content mode value should not be similar to android_gravity value and ios_content_mode
# value.
# branding_mode: bottom
# The color_dark, background_image_dark, and image_dark are parameters that set the background
# and image when the device is in dark mode. If they are not specified, the app will use the
# parameters from above. If the image_dark parameter is specified, color_dark or
# background_image_dark must be specified. color_dark and background_image_dark cannot both be
# set.
#color_dark: "#042a49"
#background_image_dark: "assets/dark-background.png"
#image_dark: assets/splash-invert.png
# The android, ios and web parameters can be used to disable generating a splash screen on a given
# platform.
#android: false
#ios: false
#web: false
# The position of the splash image can be set with android_gravity, ios_content_mode, and
# web_image_mode parameters. All default to center.
#
# android_gravity can be one of the following Android Gravity (see
# https://developer.android.com/reference/android/view/Gravity): bottom, center,
# center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal,
# fill_vertical, left, right, start, or top.
#android_gravity: center
#
# ios_content_mode can be one of the following iOS UIView.ContentMode (see
# https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill,
# scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight,
# bottomLeft, or bottomRight.
#ios_content_mode: center
#
# web_image_mode can be one of the following modes: center, contain, stretch, and cover.
#web_image_mode: center
# To hide the notification bar, use the fullscreen parameter. Has no affect in web since web
# has no notification bar. Defaults to false.
# NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads.
# To show the notification bar, add the following code to your Flutter app:
# WidgetsFlutterBinding.ensureInitialized();
# SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]);
#fullscreen: true
# If you have changed the name(s) of your info.plist file(s), you can specify the filename(s)
# with the info_plist_files parameter. Remove only the # characters in the three lines below,
# do not remove any spaces:
#info_plist_files:
# - 'ios/Runner/Info-Debug.plist'
# - 'ios/Runner/Info-Release.plist'
flutter_launcher_icons:
image_path_android: "assets/icons/icon_android.png"
image_path_ios: "assets/icons/icon_ios.png"
android: true
ios: true
remove_alpha_ios: true