From bfd4840a76205145ea46c911f45217ba0f65e92d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:48:19 +0000 Subject: [PATCH 01/67] Bump json from 2.18.0 to 2.19.2 in /android Bumps [json](https://github.com/ruby/json) from 2.18.0 to 2.19.2. - [Release notes](https://github.com/ruby/json/releases) - [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md) - [Commits](https://github.com/ruby/json/compare/v2.18.0...v2.19.2) --- updated-dependencies: - dependency-name: json dependency-version: 2.19.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- android/Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/Gemfile.lock b/android/Gemfile.lock index d27ecafb..54e5755f 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -164,7 +164,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) + json (2.19.2) jwt (2.10.2) base64 logger (1.7.0) From c27a37d52f37744fd948258696ece33fdbdd7ca9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 21:53:53 -0700 Subject: [PATCH 02/67] fix recent messages by handling valueless and escaped irc tags The recent-messages API now serializes empty IRCv3 tags as bare keys (e.g. `emotes`) instead of `emotes=`, which threw a RangeError in the tag parser and silently dropped every recent message. Parse tags per the IRCv3 spec: treat bare and empty values as equivalent, split on the first `=` so values may contain `=`, and unescape values in a single pass (\: \s \ \r \n). Also guard user extraction against prefixes without `!`, and isolate per-message parse failures in _handleIRCData so one bad message can no longer abort an entire batch. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/models/irc.dart | 89 +++++++++++++++---- .../channel/chat/stores/chat_store.dart | 17 +++- test/fixtures/irc_messages.dart | 23 +++++ test/models/irc_test.dart | 35 ++++++++ 4 files changed, 144 insertions(+), 20 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 0cb0a6bf..c2bea5d2 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -959,22 +959,25 @@ class IRCMessage { // Obtain the index to break off the tags. final tagAndIrcMessageDivider = whole.indexOf(' '); - // Get the tags substring and escape characters. - // IRC messages escape spaces with \s. - final tags = whole - .substring(1, tagAndIrcMessageDivider) - .replaceAll('\\s', ' '); - - // Next, parse and map the tags. + // Parse the IRCv3 tags into a map. The tags section is everything between + // the leading '@' and the first space. We split on the *raw* string: a + // literal ';' is always a separator because escaped semicolons inside a + // value are encoded as `\:` (unescaped per-value below). final mappedTags = {}; + final rawTags = whole.substring(1, tagAndIrcMessageDivider); - // Loop through each tag and store their key value pairs into the map. - for (final tag in tags.split(';')) { - // Skip if the tag has no value. - if (tag.endsWith('=')) continue; + for (final tag in rawTags.split(';')) { + // Split on the first '=' only — IRCv3 tag values may contain '='. + final equalsIndex = tag.indexOf('='); - final tagSplit = tag.split('='); - mappedTags[tagSplit[0]] = tagSplit[1]; + // Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`) + // and an empty value (`key=`) are equivalent — live Twitch sends the + // latter, the recent-messages API the former. + if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue; + + mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue( + tag.substring(equalsIndex + 1), + ); } // Now we'll parse the message itself. @@ -990,9 +993,19 @@ class IRCMessage { // If the username exists, set it. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. - final String? user = splitMessage[0] == _twitchIrcServer - ? mappedTags['login'] - : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); + final prefix = splitMessage[0]; + final String? user; + if (prefix == _twitchIrcServer) { + user = mappedTags['login']; + } else { + // Normal prefixes are `nick!user@host`. Fall back to the login tag (or + // null) if the expected `!` is absent so an unexpected prefix shape can + // never throw a RangeError and take down the whole message. + final bangIndex = prefix.indexOf('!'); + user = bangIndex == -1 + ? mappedTags['login'] + : prefix.substring(0, bangIndex); + } // If there is an associated message, set it. String? message; @@ -1153,6 +1166,50 @@ class IRCMessage { ); } + /// Unescapes an IRCv3 message-tag value. + /// + /// The spec defines these sequences: `\:` → `;`, `\s` → space, `\\` → `\`, + /// `\r` → CR, `\n` → LF. Any other `\x` collapses to `x`, and a lone trailing + /// backslash is dropped. Done in a single pass so sequences can't be + /// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space). + static String _unescapeTagValue(String value) { + // Fast path: the vast majority of tag values contain no escape sequences. + if (!value.contains('\\')) return value; + + final buffer = StringBuffer(); + for (var i = 0; i < value.length; i++) { + if (value[i] != '\\') { + buffer.write(value[i]); + continue; + } + + // A trailing backslash with nothing after it is dropped. + if (i + 1 == value.length) break; + + switch (value[i + 1]) { + case ':': + buffer.write(';'); + break; + case 's': + buffer.write(' '); + break; + case '\\': + buffer.write('\\'); + break; + case 'r': + buffer.write('\r'); + break; + case 'n': + buffer.write('\n'); + break; + default: + buffer.write(value[i + 1]); + } + i++; // Skip the escaped character we just consumed. + } + return buffer.toString(); + } + factory IRCMessage.createNotice({ required String message, VoidCallback? actionCallback, diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..ccc93d6c 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -519,10 +519,19 @@ abstract class ChatStoreBase with Store { for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); if (message.startsWith('@')) { - final parsedIRCMessage = IRCMessage.fromString( - message, - userLogin: auth.user.details?.login, - ); + // Parse defensively: a single malformed message must never abort the + // loop, or one bad frame would drop every other message batched with + // it (e.g. an entire recent-messages backlog). Skip and keep going. + final IRCMessage parsedIRCMessage; + try { + parsedIRCMessage = IRCMessage.fromString( + message, + userLogin: auth.user.details?.login, + ); + } catch (e) { + debugPrint('Failed to parse IRC message: $e'); + continue; + } if (parsedIRCMessage.user != null) { chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); diff --git a/test/fixtures/irc_messages.dart b/test/fixtures/irc_messages.dart index 28624337..a5bcbfaa 100644 --- a/test/fixtures/irc_messages.dart +++ b/test/fixtures/irc_messages.dart @@ -9,6 +9,19 @@ const basicPrivmsg = 'tmi-sent-ts=1234567890123;turbo=0;user-id=67890;user-type= ' ':testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello World'; +/// Exercises the full IRCv3 tag escape set plus a value that itself contains +/// '='. Uses a raw string so the backslashes are literal wire bytes. +const messageWithEscapedTagChars = + r'@a-space=one\stwo;a-semi=left\:right;a-slash=a\\b;a-equals=k=v;' + r'a-double=x\\sy;display-name=EscapeAll;mod=0 ' + r':escapeall!escapeall@escapeall.tmi.twitch.tv PRIVMSG #channel :hi'; + +/// A message whose prefix is not the usual `nick!user@host` form (no '!'). +/// The parser should fall back to the login tag instead of throwing. +const messageNoBangPrefix = + '@login=fallbackuser;mod=0;id=nobang-1 ' + ':some.server.example PRIVMSG #channel :server style'; + /// A chat message with emotes in the tags /// Note: emote positions are 0-indexed byte positions in the message /// "Kappa LUL" - Kappa is at 0-4, LUL is at 6-8 @@ -138,6 +151,16 @@ const historicalMessage = 'tmi-sent-ts=1234567890123;turbo=0;user-id=12121;user-type= ' ':histuser!histuser@histuser.tmi.twitch.tv PRIVMSG #channel :Historical message'; +/// A historical message as serialized by the recent-messages API, which emits +/// empty tags as bare keys (e.g. `emotes`, `flags`) instead of the `key=` form +/// that live Twitch IRC uses. +const historicalMessageBareTags = + '@badges=raging-wolf-helm/1;emotes;first-msg=0;tmi-sent-ts=1234567890123;' + 'flags;mod=0;returning-chatter=0;rm-received-ts=1234567890456;color=#1E90FF;' + 'id=hist-bare-123;room-id=12345;historical=1;user-type;turbo=0;subscriber=0;' + 'badge-info;display-name=BareUser;user-id=18181 ' + ':bareuser!bareuser@bareuser.tmi.twitch.tv PRIVMSG #channel :Bare tag message'; + /// A message from a shared chat session (source room different from current) const sharedChatMessage = '@badge-info=;badges=;color=#00BFFF;display-name=SharedUser;emotes=;' diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 2228a54c..f37a39eb 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -27,6 +27,27 @@ void main() { expect(msg.message, 'test message'); }); + test('unescapes the full IRCv3 tag escape set', () { + final msg = IRCMessage.fromString(messageWithEscapedTagChars); + + expect(msg.tags['a-space'], 'one two'); + expect(msg.tags['a-semi'], 'left;right'); + expect(msg.tags['a-slash'], r'a\b'); + // Only the first '=' splits key/value; later '=' stays in the value. + expect(msg.tags['a-equals'], 'k=v'); + // `\\` consumes both backslashes; the following 's' is a literal letter, + // not a `\s` space escape (single-pass unescaping). + expect(msg.tags['a-double'], r'x\sy'); + }); + + test('falls back to login tag when prefix has no "!"', () { + // An unexpected prefix shape must not throw and drop the message. + final msg = IRCMessage.fromString(messageNoBangPrefix); + + expect(msg.user, 'fallbackuser'); + expect(msg.message, 'server style'); + }); + test('handles message with no color tag', () { final msg = IRCMessage.fromString(messageNoColor); @@ -223,6 +244,20 @@ void main() { expect(msg.tags['historical'], '1'); }); + test('parses historical message with bare (valueless) tags', () { + // The recent-messages API serializes empty tags as bare keys (e.g. + // `emotes`, `flags`) instead of `key=`. This previously threw a + // RangeError, silently dropping all recent messages. + final msg = IRCMessage.fromString(historicalMessageBareTags); + + expect(msg.tags['historical'], '1'); + expect(msg.tags['display-name'], 'BareUser'); + expect(msg.message, 'Bare tag message'); + // Bare tags should be absent from the map, not crash parsing. + expect(msg.tags.containsKey('emotes'), isFalse); + expect(msg.tags.containsKey('flags'), isFalse); + }); + test('parses shared chat message with source room', () { final msg = IRCMessage.fromString(sharedChatMessage); From e24491639dafab2c8fb43cc2ecfe2d832efaad7a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:01:47 -0700 Subject: [PATCH 03/67] docs: adopt conventional commit format in AGENTS.md Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2959400d..d9145970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,4 +52,6 @@ dart run build_runner build --delete-conflicting-outputs # Same, but ## Commits -Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped. +Conventional commits: `type(scope): description` — lowercase, no trailing period, tightly scoped (e.g., `fix(chat): correct landscape bottom padding in bottom bar`). Append `!` before the `:` for breaking changes (e.g., `feat(api)!: drop legacy auth flow`). + +Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `style`. Scope is the affected area (e.g., `chat`, `video`, `api`). From d2b4f0f39e026d1efe53826014b6bea99240af5a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:01:47 -0700 Subject: [PATCH 04/67] chore(chat): migrate reorderable tab list to onReorderItem for flutter 3.44 ReorderableListView's onReorder was deprecated after Flutter 3.41 and fails flutter analyze on the CI's pinned 3.44.1 toolchain. Switch to onReorderItem, which adjusts newIndex for the removed item itself, and drop the now-redundant manual off-by-one correction in reorderTab. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/screens/channel/chat/stores/chat_tabs_store.dart | 6 ++---- lib/screens/channel/chat/widgets/chat_tabs.dart | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 0fc54c46..8d3f1c8d 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -620,10 +620,8 @@ abstract class ChatTabsStoreBase with Store { // Primary tab (index 0) cannot be moved, and nothing can move before it if (oldIndex == 0 || newIndex == 0) return; - // ReorderableListView passes newIndex as if item was already removed - if (oldIndex < newIndex) { - newIndex -= 1; - } + // onReorderItem already adjusts newIndex for the removed item, so no + // manual off-by-one correction is needed here. if (oldIndex == newIndex) return; final tab = _tabs.removeAt(oldIndex); diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 500831b4..fe946202 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -144,7 +144,7 @@ class ChatTabs extends StatelessWidget { left: 12, right: showMerge ? 32 : 12, ), - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { HapticFeedback.lightImpact(); chatTabsStore.reorderTab(oldIndex, newIndex); }, From a3c9ca88f8e7182aff78c5a205637507d20b605c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:29:20 -0700 Subject: [PATCH 05/67] chore(deps): support flutter 3.44 stable (simple_icons, extended_text_field) The CI/release toolchain uses the latest stable Flutter, now 3.44.1, which broke two dependencies: - simple_icons 14.6.1 extends IconData, now a final class -> bump to 16.23.0. - extended_text_field 16.0.2 (latest pub release) vendors an EditableText copy referencing ExtendSelectionByPageIntent, removed in 3.44 -> point at a fork (tommyxchow/extended_text_field@76305e9) that drops the unused handler, pinned by commit SHA. Restores flutter analyze (no issues) and flutter test (all pass) on 3.44.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- pubspec.lock | 21 ++- pubspec.yaml | 505 ++++++++++++++++++++++++++------------------------- 2 files changed, 266 insertions(+), 260 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6eb900ef..11a70b52 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -316,10 +316,11 @@ packages: extended_text_field: dependency: "direct main" description: - name: extended_text_field - sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" - url: "https://pub.dev" - source: hosted + path: "." + ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + url: "https://github.com/tommyxchow/extended_text_field.git" + source: git version: "16.0.2" extended_text_library: dependency: transitive @@ -788,10 +789,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1108,10 +1109,10 @@ packages: dependency: "direct main" description: name: simple_icons - sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" + sha256: "21246ffcaaf9f560695596093c2ecf9fa763fd112a15916fbe7d282932430bae" url: "https://pub.dev" source: hosted - version: "14.6.1" + version: "16.23.0" simple_pip_mode: dependency: "direct main" description: @@ -1242,10 +1243,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3be38265..f5f69184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,250 +1,255 @@ -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: 5.0.5+68 - -environment: - sdk: ^3.9.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 - web_socket_channel: ^3.0.0 - flutter_secure_storage: ^9.2.2 - intl: ^0.20.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: ^9.0.0 - collection: ^1.16.0 - flutter_cache_manager: ^3.3.0 - device_info_plus: ^12.3.0 - wakelock_plus: ^1.1.2 - simple_icons: ^14.6.1 - simple_pip_mode: - git: - url: https://github.com/tommyxchow/simple_pip_mode_flutter - firebase_core: ^4.1.0 - firebase_crashlytics: ^5.0.1 - firebase_performance: ^0.11.0+1 - firebase_analytics: ^12.0.1 - advanced_in_app_review: ^1.1.3 - webview_flutter_android: ^4.0.2 - webview_flutter_wkwebview: ^3.4.3 - photo_view: ^0.15.0 - flutter_markdown_plus: ^1.0.7 - flutter_colorpicker: ^1.1.0 - path_provider: ^2.1.5 - dio: ^5.9.0 - app_links: ^7.0.0 - extended_text_field: ^16.0.2 - native_device_orientation: ^2.1.0 - -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: ^6.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.14.1 - mocktail: ^1.0.4 - http_mock_adapter: ^0.6.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 +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: 5.0.5+68 + +environment: + sdk: ^3.9.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 + web_socket_channel: ^3.0.0 + flutter_secure_storage: ^9.2.2 + intl: ^0.20.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: ^9.0.0 + collection: ^1.16.0 + flutter_cache_manager: ^3.3.0 + device_info_plus: ^12.3.0 + wakelock_plus: ^1.1.2 + simple_icons: ^16.23.0 + simple_pip_mode: + git: + url: https://github.com/tommyxchow/simple_pip_mode_flutter + firebase_core: ^4.1.0 + firebase_crashlytics: ^5.0.1 + firebase_performance: ^0.11.0+1 + firebase_analytics: ^12.0.1 + advanced_in_app_review: ^1.1.3 + webview_flutter_android: ^4.0.2 + webview_flutter_wkwebview: ^3.4.3 + photo_view: ^0.15.0 + flutter_markdown_plus: ^1.0.7 + flutter_colorpicker: ^1.1.0 + path_provider: ^2.1.5 + dio: ^5.9.0 + app_links: ^7.0.0 + # Fork pinned for Flutter 3.44 compatibility — upstream 16.0.2 is the latest + # pub release and references ExtendSelectionByPageIntent, removed in 3.44. + extended_text_field: + git: + url: https://github.com/tommyxchow/extended_text_field.git + ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89 + native_device_orientation: ^2.1.0 + +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: ^6.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.14.1 + mocktail: ^1.0.4 + http_mock_adapter: ^0.6.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 From 29c4f74ef5b4f46520ecefd56542cfc283508ae3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:33:24 -0700 Subject: [PATCH 06/67] ci: pin flutter to 3.44.1 in all workflows Workflows used subosito/flutter-action with no version, silently tracking GitHub's latest stable. When stable rolled to 3.44.1 it broke the build with no code change. Pin explicitly so builds are reproducible and Flutter upgrades are a deliberate bump rather than a surprise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 + .github/workflows/gh-release.yml | 1 + .github/workflows/pr-check.yml | 1 + .github/workflows/store-release.yml | 2 ++ 4 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d49bfab7..3e77df25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true - run: flutter doctor -v - run: flutter pub get diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index 1846001d..da793ce9 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -32,6 +32,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Decode and create the upload-keystore.jks file, used for signing the app. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 5a454846..132c632e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,6 +10,7 @@ jobs: - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true - run: flutter doctor -v - run: flutter pub get diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index 75afb083..84b97315 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -49,6 +49,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -100,6 +101,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). From f5718e36562abf30b472ac170a7e9667f77f8e40 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:42:37 -0700 Subject: [PATCH 07/67] fix(chat): restore channel assets when leaving shared chat When a shared chat session ended, the participants' merged emotes and badges stayed in the channel asset maps, so the other streamer's emote set and badges kept showing (#522). Only the enter transition was handled. Detect the leave transition (was shared, no longer shared) and reset: clear the loaded-participant tracking and re-run getAssets(), which replaces the channel emote/badge maps with this channel's own assets. Guarded on having actually loaded shared assets so a flapping source-room-id tag can't cause redundant refetches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channel/chat/stores/chat_assets_store.dart | 12 ++++++++++++ lib/screens/channel/chat/stores/chat_store.dart | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index c1ec05f7..2efd626c 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -290,6 +290,18 @@ abstract class ChatAssetsStoreBase with Store { await Future.wait(futures); } + /// Whether any shared chat participant assets have been merged in. + bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty; + + /// Clears tracking of merged shared chat participant assets so that leaving + /// and later re-entering a shared chat session re-fetches cleanly. The caller + /// must re-fetch this channel's own assets afterwards (which replaces the + /// merged emote/badge maps). Must run within a MobX action. + void resetSharedChatAssets() { + _loadedSharedChannelIds.clear(); + sharedSevenTvSetIds.clear(); + } + /// Load and cache shared chat participants for a broadcaster. /// Returns the list of new participant broadcaster IDs (deduped). @action diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index ccc93d6c..4ed851ea 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -611,6 +611,16 @@ abstract class ChatStoreBase with Store { showFFZEmotes: settings.showFFZEmotes, showFFZBadges: settings.showFFZBadges, ); + } else if (wasShared && + !_isInSharedChatMode && + assetsStore.hasLoadedSharedChatAssets) { + // On leaving shared chat, drop the participants' merged emotes and + // badges and restore just this channel's assets — otherwise the + // other streamer's emote set and badges linger (#522). Guarded on + // having actually loaded shared assets so a flapping tag can't + // trigger redundant refetches. + assetsStore.resetSharedChatAssets(); + getAssets(); } messageBuffer.add(parsedIRCMessage); break; From 17bbb20b94c70351e72b22c7513b0f3e8dba33e2 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:15:38 -0700 Subject: [PATCH 08/67] build(android): guard release signing to allow keyless compile checks Release builds now use the real upload key when key.properties is present, fall back to debug signing only when ALLOW_DEBUG_SIGNED_RELEASE is set (CI compile-only checks), and otherwise fail loudly. Lets CI compile a release APK without production secrets while ensuring a real release can never be silently debug-signed if the keystore step goes missing. Co-Authored-By: Claude Opus 4.8 (1M context) --- android/app/build.gradle.kts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c9f675d5..596b2812 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -50,7 +50,16 @@ android { buildTypes { release { - signingConfig = signingConfigs.getByName("release") + // Use the real upload key when key.properties is present (release + // workflows create it from secrets). Allow debug signing only when a CI + // compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE; + // otherwise fail loudly so a real release can never be silently + // debug-signed if the keystore step is ever missing. + signingConfig = when { + keystorePropertiesFile.exists() -> signingConfigs.getByName("release") + System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug") + else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds") + } } } } From 7f4abdefeabd43a489b5a0a051b0111a0a1a55be Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:15:38 -0700 Subject: [PATCH 09/67] ci: add release dry-run workflow and release-mode compile checks Add a weekly + manual release-dryrun workflow that exercises the full store pipeline without publishing: Android validates the AAB against Google Play (validate_only), iOS builds a fully signed archive via match (readonly) and skips the App Store upload. Both Fastlane lanes gain a dry_run option that reuses the real release path so the dry-run stays faithful to it. Also add release-mode compile checks (flutter build apk/ios --release) to CI and PR checks to catch release-only breakage, pinning the JDK to match the release toolchain. Trim ci.yml from a redundant 3-OS matrix to two purpose-built jobs (analyze/test once on Linux, iOS build on macOS). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 34 ++++++-- .github/workflows/pr-check.yml | 13 +++ .github/workflows/release-dryrun.yml | 118 +++++++++++++++++++++++++++ android/fastlane/Fastfile | 7 +- ios/fastlane/Fastfile | 14 +++- 5 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release-dryrun.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e77df25..93c2d12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,20 @@ on: push: branches: [main] jobs: - test: - name: Check and test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # Linux runner: analyze + test once (both are OS-independent) and compile a + # release APK to catch release-only breakage. Debug-signed via the build.gradle + # fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed. + android: + name: Analyze, test, and build Android + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so this compile check + # faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: flutter-version: 3.44.1 @@ -19,3 +25,19 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' + # macOS runner: release-compile iOS (no codesign, no secrets) — the only thing + # that must run on macOS. analyze/test already covered on the Linux job. + ios: + name: Build iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + - run: flutter pub get + - run: flutter build ios --release --no-codesign diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 132c632e..2b632d17 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -8,6 +8,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so the release-mode APK + # check below faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: flutter-version: 3.44.1 @@ -16,3 +22,10 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + # Release-mode compile check (no signing secrets needed — Android falls back + # to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only + # breakage that debug builds and tests miss (AOT compile, icon tree-shaking, + # plugin/Gradle release incompatibility). + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml new file mode 100644 index 00000000..7941f249 --- /dev/null +++ b/.github/workflows/release-dryrun.yml @@ -0,0 +1,118 @@ +# Dry-runs the full store release pipeline without publishing, to catch +# credential, signing, dependency, and store-validation breakage before an +# actual release. Mirrors store-release.yml but stops short of publishing: +# Android validates the AAB against Google Play (validate_only); iOS builds a +# fully signed archive via match (readonly) and skips the App Store upload. +name: Release dry-run +on: + # Run on demand before cutting a release. + workflow_dispatch: + # Weekly heartbeat so cert expiry, secret rotation, and dependency drift + # surface on their own between releases. + schedule: + - cron: '0 9 * * 1' # Mondays 09:00 UTC +# Setup environment variables for use in all jobs. +env: + # Gemfile path used for caching dependencies. + BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile + CLIENT_ID: ${{ secrets.CLIENT_ID }} + SECRET: ${{ secrets.SECRET }} + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 +jobs: + android: + name: Dry-run Android release + runs-on: ubuntu-latest + environment: release-android + env: + UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_FILE_PATH: android/key.properties + PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }} + + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + + # Setup Java so that we can build the appbundle. + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + + # Decode and create the upload-keystore.jks file, used for signing the app. + - name: Decode and create upload-keystore.jks + run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks + + # Create the key.properties file, used for signing the app. + - name: Create key.properties + run: | + echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH + echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH + echo keyAlias=upload >> $KEY_FILE_PATH + echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH + + # Build the Android app bundle (AAB) and pass the environment variables. + - name: Build the Android AAB + run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Validate the AAB and credentials against Google Play without publishing. + - name: Validate release with fastlane (no publish) + run: cd android && bundle exec fastlane deploy dry_run:true + ios: + name: Dry-run iOS release + runs-on: macos-latest + environment: release-ios + env: + APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} + APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }} + APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Always pick the newest production Xcode on the runner + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + + # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). + - name: Config the iOS build + run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Authenticate ASC API key, resolve signing via match (readonly), and build a + # fully signed archive without uploading to the App Store. + - name: Sign and build with fastlane (no publish) + run: cd ios && bundle exec fastlane release dry_run:true diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 0390c22d..d2392565 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -17,11 +17,14 @@ default_platform(:android) platform :android do desc "Deploy a new version to the Google Play" - lane :deploy do + lane :deploy do |options| # Upload to the play store ussing the provided credentials and AAB from flutter build. + # With dry_run:true (release dry-run workflow), validate_only authenticates and + # validates the AAB against Google Play without committing the release. upload_to_play_store( json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]), - aab: "../build/app/outputs/bundle/release/app-release.aab" + aab: "../build/app/outputs/bundle/release/app-release.aab", + validate_only: options[:dry_run] || false ) end end diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 78bcb074..df241cb0 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -17,7 +17,9 @@ default_platform(:ios) platform :ios do desc 'Push a new release build to the App Store' - lane :release do + lane :release do |options| + dry_run = options[:dry_run] || false + # Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts. setup_ci @@ -29,8 +31,9 @@ platform :ios do is_key_content_base64: true ) - # Manage code signing (with match). - sync_code_signing + # Manage code signing (with match). readonly on a dry run so we only verify the + # existing cert/profile resolve and never regenerate or mutate the certificates repo. + sync_code_signing(readonly: dry_run) # Automatic signing is used during development, but fastlane needs to use manual signing. update_code_signing_settings( @@ -45,6 +48,11 @@ platform :ios do output_directory: '../build/ios/archive' ) + # On a dry run, stop here: the signed archive above exercises every step that + # actually fails (ASC key, match certs/profiles, signing, release build). The + # App Store upload itself is the only remaining unverified step. + next if dry_run + # Upload the app to the App Store. upload_to_app_store( submit_for_review: true, From 8883af90f05dbf4a284092594c08f0154a53e083 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:39:51 -0700 Subject: [PATCH 10/67] fix(ios): resolve Crashlytics upload-symbols for SPM builds The Crashlytics 'Upload Symbols' build phase was hardcoded to the CocoaPods path ($PODS_ROOT/FirebaseCrashlytics/upload-symbols), but Flutter 3.44 + firebase_crashlytics 5.x resolve Firebase via Swift Package Manager, so that path no longer exists and every release build (including fastlane build_app) failed with 'No such file or directory'. Fall back to the SPM checkout path when the pod isn't present so the upload works under either integration. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Runner.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c0a4184..e82e9a80 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -246,7 +246,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; + shellScript = "# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; From f2b932bc93054e710d2ea3e3dbd44df735cd78a6 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 13 Jun 2026 17:06:24 -0400 Subject: [PATCH 11/67] feat(video,chat): native player, ad-break awareness, chat send/delay reliability, and tab redesign (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merge commit. Covers four areas: native video player, chat reliability, multi-tab, and hardening. ## Native video player - Direct HLS playback via `better_native_video_player` (forked from `plug-and-pay/flutter-native-video-player`, patched on `frosty-patches` branch) - Twitch GQL playback token fetching with platform cookie extraction (iOS Swift + Android Kotlin plugins) for authenticated streams - Quality selection (source, 720p, 480p, 360p, 160p, audio-only) persisted per channel - Picture-in-picture: iOS automatic inline PiP, Android auto-PiP on API 31+ guarded against older devices - Latency polling, chat latency sync, stall detection/recovery, startup auto-pause recovery, watchdog timers - **Twitch stitched-ad break detection** (HLS `EXT-X-DATERANGE`) on both platforms, emitting `adBreakChanged` so the host suspends latency/stall recovery and chat sync during ads (program-date-time is unreliable then). Matches `CLASS="twitch-stitched-ad"` / `ID="stitched-ad-"` **and** the `X-TV-TWITCH-AD-*` attribute prefix (the durable signal streamlink/yt-dlp use) - Shared `VideoPlayerInterface` so WebView and native paths are interchangeable via settings toggle - `PlaybackAccessToken` model, `TwitchGqlApi` client ## Chat delay & send reliability - **Optimistic send-finalize**: Twitch acks a sent message with a per-PRIVMSG USERSTATE (not an echo), which it can intermittently drop — more so under a long chat delay. A missing ack no longer shows a false "message may not have been sent" error; the send is finalized optimistically (local echo rendered), and only genuine rejection NOTICEs surface an error. Ack timeout scaled by the chat delay. - **Per-line chat-delay bypass**: control/state lines (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), rejection NOTICEs, the reconnect notice, and untagged server lines bypass the delay so send-acks and PONGs aren't stalled; only displayable chat content is delayed, staying in sync with delayed video. - **Measured chat bottom-bar height**: the message list reflows to the composer's real measured height, fixing the input overlapping messages when a long message expands it (replaces a fixed-constant estimate). - IRC tag parsing handles valueless and escaped tags per the IRCv3 message-tags spec (fixes a recent-messages RangeError on certain tags). ## Chat / multi-tab - Redesigned tab chips: unread dot indicator, chips dim until WebSocket connects - Animated tab options popover replacing `MenuAnchor` (no animation hook in Flutter 3.44) - Simplified `ChatTabs` from `StatefulWidget` to `StatelessWidget` - Focus-current-channel fade bump to 0.55; section-header alpha lifted to match the muted-label tier - `addTab` now calls `setActiveTab` to snapshot outgoing tab's `lastSeenMessageId` — fixes spurious unread dots after adding a tab - `onActivateSourceTab` threaded into `ChatUserModal` so Reply in merged mode switches to the correct source tab's composer ## Bug fixes and hardening - **Reverted the experimental iOS low-latency mode** (5s live-edge join + 1.05x catch-up): AVPlayer can't sustain <~18s on Twitch (declared `TARGETDURATION:6` paces playlist refreshes), and the offset starved the buffer into pause-on-launch. Default join distance restored. - Fork (Android): **cap `BEHIND_LIVE_WINDOW` recovery** so a stream repeatedly falling off the live window can't silently re-prepare forever; emits `error` on exhaustion so the host recovery ladder takes over - Guard all Android `setAutoPipMode` calls with `_autoPipAvailable` — prevents `PlatformException` on Android 8–11; check `_disposed` after `isAutoPipAvailable` await to prevent post-dispose auto-PiP re-enable - Remove `_isInPipMode` guard from `_onPaused` so `_paused` tracks actual state, fixing PiP play/pause toggle getting stuck - Tighten iOS cookie domain check to exact `twitch.tv` / `.twitch.tv` suffix - Invalidate the stored web session token when Twitch rejects it with a 401 (profile card flips back to unlinked; future loads skip the doomed authenticated request), and re-extract the cookie on launch when logged in without a linked session - Send `play_session_id` and `player_backend=mediaplayer` to Usher, matching the web client / streamlink - Fork (iOS): restore program-date-time latency measurement (a prior rev measured distance to the manifest edge, reading ~0s and silently disabling high-latency recovery / chat-delay auto-sync); release platform views on dispose (per-stream view + AVPlayerViewController leak), track the observed AVPlayerItem for KVO removal (sibling-view crash), main-thread seek results, resolve superseded load futures - Fork (Android): clean up method handlers orphaned by PiP-time view disposal (leaked audio-focus listeners), drop the live-offset re-anchoring seekTo on metadata updates (latency creep), restore pre-duck volume, respect user pause in the stall watchdog, resolve superseded load futures - Deep review pass: expire the quality-switch flag so stall recovery isn't disabled for the session, cap latency-driven recovery (clock-skew loop), generation-guard the error overlay, two-strike offline confirmation, controller null-safety on quality switch - Tighten `/me` local-echo match (no longer renders `/meow ...` as an action); clear pending send text on disconnect ## Web / docs - Remove stale Cloudflare Images binding from `wrangler.jsonc` (covered by `images.unoptimized: true`) - Correct `web/AGENTS.md` Cache Components claim (feature not enabled) - Repo and agent config cleanup ## Test plan - [x] `flutter analyze` — clean (frosty + plugin lib) - [x] `flutter test` — 319/319 pass (adds `ChatLatencySync`, `StreamInfoPoller`, video timing invariants, `PlaybackAccessToken`, IRC tag parsing) - [x] Android plugin Kotlin compiles against the bumped fork rev - [ ] Native player plays live streams on iOS and Android - [ ] Quality switching works and persists across channel reopens - [ ] PiP enters/exits correctly; pause/resume toggle in PiP window works - [ ] Audio-only mode does not crash on Android < API 31 - [ ] Sending under a long synced chat delay no longer shows a false "may not have been sent" error - [ ] Long composer text reflows the message list (no input/message overlap) - [ ] Multi-tab: adding a tab does not mark previous tab as unread - [ ] Merged chat: Reply from user modal targets the correct tab's composer 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .claude/settings.json | 31 - AGENTS.md | 38 +- .../tommychow/frosty/CookieExtractorPlugin.kt | 32 + .../com/tommychow/frosty/MainActivity.kt | 5 + android/gradle.properties | 12 +- ios/Podfile.lock | 287 -- ios/Runner.xcodeproj/project.pbxproj | 28 +- .../xcshareddata/swiftpm/Package.resolved | 122 + .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 122 + ios/Runner/AppDelegate.swift | 7 + ios/Runner/CookieExtractorPlugin.swift | 27 + ios/Runner/Info.plist | 4 + lib/apis/twitch_gql_api.dart | 132 + lib/apis/unauthorized_interceptor.dart | 11 +- lib/constants.dart | 12 + lib/main.dart | 10 + lib/models/irc.dart | 21 +- lib/models/playback_access_token.dart | 65 + lib/screens/channel/channel.dart | 199 +- lib/screens/channel/chat/chat.dart | 7 + .../channel/chat/details/chat_modes.dart | 2 +- .../channel/chat/stores/chat_store.dart | 338 +- .../channel/chat/stores/chat_tabs_store.dart | 45 +- .../channel/chat/widgets/chat_bottom_bar.dart | 12 +- .../channel/chat/widgets/chat_message.dart | 49 +- .../channel/chat/widgets/chat_tabs.dart | 351 +- .../channel/chat/widgets/chat_user_modal.dart | 4 + .../channel/chat/widgets/reply_thread.dart | 2 +- .../channel/video/chat_latency_sync.dart | 32 + lib/screens/channel/video/native_video.dart | 106 + .../video/native_video_player_interface.dart | 12 + .../channel/video/native_video_store.dart | 1308 +++++++ .../channel/video/native_video_store.g.dart | 497 +++ .../channel/video/stream_info_poller.dart | 86 + lib/screens/channel/video/video_overlay.dart | 104 +- .../channel/video/video_player_interface.dart | 43 + lib/screens/channel/video/video_store.dart | 774 +++-- lib/screens/channel/video/video_store.g.dart | 20 + .../channel/video/video_timing_constants.dart | 67 + lib/screens/home/home.dart | 25 +- .../account/widgets/profile_card.dart | 60 +- lib/screens/settings/chat_settings.dart | 736 ++-- lib/screens/settings/general_settings.dart | 4 +- lib/screens/settings/stores/auth_store.dart | 41 +- lib/screens/settings/stores/auth_store.g.dart | 32 + .../settings/stores/settings_store.dart | 212 +- .../settings/stores/settings_store.g.dart | 534 +-- lib/screens/settings/video_settings.dart | 27 +- lib/services/cookie_extractor.dart | 9 + lib/theme.dart | 59 +- lib/utils/context_extensions.dart | 18 +- lib/widgets/expandable_section_header.dart | 12 +- lib/widgets/live_indicator.dart | 90 +- lib/widgets/measure_size.dart | 46 + lib/widgets/profile_picture.dart | 6 +- lib/widgets/section_header.dart | 10 +- lib/widgets/settings_page_layout.dart | 2 +- pubspec.lock | 3033 +++++++++-------- pubspec.yaml | 4 + test/models/playback_access_token_test.dart | 78 + .../channel/video/chat_latency_sync_test.dart | 111 + .../video/stream_info_poller_test.dart | 194 ++ .../video/video_timing_constants_test.dart | 159 + web/.gitignore | 1 + web/AGENTS.md | 47 +- web/package.json | 2 - web/pnpm-lock.yaml | 420 --- web/public/_headers | 6 - web/src/app/globals.css | 2 - web/src/components/Providers.tsx | 9 - web/wrangler.jsonc | 5 - 72 files changed, 7220 insertions(+), 3816 deletions(-) delete mode 100644 .claude/settings.json create mode 100644 android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner/CookieExtractorPlugin.swift create mode 100644 lib/apis/twitch_gql_api.dart create mode 100644 lib/models/playback_access_token.dart create mode 100644 lib/screens/channel/video/chat_latency_sync.dart create mode 100644 lib/screens/channel/video/native_video.dart create mode 100644 lib/screens/channel/video/native_video_player_interface.dart create mode 100644 lib/screens/channel/video/native_video_store.dart create mode 100644 lib/screens/channel/video/native_video_store.g.dart create mode 100644 lib/screens/channel/video/stream_info_poller.dart create mode 100644 lib/screens/channel/video/video_player_interface.dart create mode 100644 lib/screens/channel/video/video_timing_constants.dart create mode 100644 lib/services/cookie_extractor.dart create mode 100644 lib/widgets/measure_size.dart create mode 100644 test/models/playback_access_token_test.dart create mode 100644 test/screens/channel/video/chat_latency_sync_test.dart create mode 100644 test/screens/channel/video/stream_info_poller_test.dart create mode 100644 test/screens/channel/video/video_timing_constants_test.dart diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 260fee95..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "enabledMcpjsonServers": ["next-devtools"], - "enabledPlugins": { - "frontend-design@claude-plugins-official": true, - "feature-dev@claude-plugins-official": true, - "commit-commands@claude-plugins-official": true, - "pr-review-toolkit@claude-plugins-official": true, - "typescript-lsp@claude-plugins-official": true, - "claude-md-management@claude-plugins-official": true, - "claude-code-setup@claude-plugins-official": true, - "skill-creator@claude-plugins-official": true - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "dart format \"$CLAUDE_FILE_PATH\" 2>/dev/null || true" - } - ] - } - ] - }, - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true, - "enableWeakerNetworkIsolation": true - } -} diff --git a/AGENTS.md b/AGENTS.md index d9145970..3501b26c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,29 @@ # AGENTS.md -> **Monorepo note:** `web/` is a separate Next.js landing page with its own `AGENTS.md`. This file covers the Flutter app only. +--- -## Workflow +# Author Preferences -- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding -- When new code supersedes existing functionality, find and remove everything it makes redundant -- Favor parallel tool calls and subagents when tasks are independent +## Code Opinions + +- Package imports (`import 'package:frosty/...'`), not relative imports +- Trailing commas always +- Single quotes +- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering + +## Infrastructure Checklist + +When creating new infrastructure (screens, API clients, stores), use exploration findings as a **checklist** — systematically verify each convention is followed before writing code. + +## Never + +- Never edit `.g.dart` files directly — always regenerate with `dart run build_runner build` + +--- + +# Project + +Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state management with code generation. ## Commands @@ -29,7 +46,7 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - `lib/widgets/` — Shared widgets - `lib/utils/` — Utility functions -## Architecture (non-obvious bits) +## Architecture - Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores) - All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes @@ -39,9 +56,6 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control. - The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it. -- Use package imports (`import 'package:frosty/...'`), not relative imports -- Always include trailing commas -- Use single quotes ## Testing @@ -49,9 +63,3 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost` - General mocking: `mocktail` (no codegen required) - Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`) - -## Commits - -Conventional commits: `type(scope): description` — lowercase, no trailing period, tightly scoped (e.g., `fix(chat): correct landscape bottom padding in bottom bar`). Append `!` before the `:` for breaking changes (e.g., `feat(api)!: drop legacy auth flow`). - -Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `style`. Scope is the affected area (e.g., `chat`, `video`, `api`). diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -0,0 +1,32 @@ +package com.tommychow.frosty + +import android.webkit.CookieManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "extractTwitchAuthToken") { + val cookies = CookieManager.getInstance().getCookie("https://twitch.tv") + val authToken = cookies?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.startsWith("auth-token=") } + ?.substringAfter("auth-token=") + result.success(authToken) + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(CookieExtractorPlugin()) + } } diff --git a/android/gradle.properties b/android/gradle.properties index 01a11bb7..6fdfce35 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,8 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -org.gradle.caching=true -org.gradle.parallel=true +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +org.gradle.caching=true +org.gradle.parallel=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a571234b..6cba02bd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,314 +1,27 @@ PODS: - advanced_in_app_review (0.0.1): - Flutter - - app_links (7.0.0): - - Flutter - - device_info_plus (0.0.1): - - Flutter - - Firebase/CoreOnly (12.9.0): - - FirebaseCore (~> 12.9.0) - - Firebase/Crashlytics (12.9.0): - - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.9.0) - - Firebase/Performance (12.9.0): - - Firebase/CoreOnly - - FirebasePerformance (~> 12.9.0) - - firebase_analytics (12.1.3): - - firebase_core - - FirebaseAnalytics (= 12.9.0) - - Flutter - - firebase_core (4.5.0): - - Firebase/CoreOnly (= 12.9.0) - - Flutter - - firebase_crashlytics (5.0.8): - - Firebase/Crashlytics (= 12.9.0) - - firebase_core - - Flutter - - firebase_performance (0.11.1-5): - - Firebase/Performance (= 12.9.0) - - firebase_core - - Flutter - - FirebaseABTesting (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseAnalytics (12.9.0): - - FirebaseAnalytics/Default (= 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleAppMeasurement/Default (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseCore (12.9.0): - - FirebaseCoreInternal (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreInternal (12.9.0): - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfigInterop (~> 12.9.0) - - FirebaseSessions (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - nanopb (~> 3.30910.0) - - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.9.0): - - FirebaseCore (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - PromisesObjC (~> 2.4) - - FirebasePerformance (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfig (~> 12.9.0) - - FirebaseSessions (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - - FirebaseRemoteConfig (12.9.0): - - FirebaseABTesting (~> 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfigInterop (~> 12.9.0) - - FirebaseSharedSwift (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseRemoteConfigInterop (12.9.0) - - FirebaseSessions (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreExtension (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (12.9.0) - Flutter (1.0.0) - - flutter_native_splash (2.4.3): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - - GoogleAdsOnDeviceConversion (3.2.0): - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.9.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.9.0): - - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleAppMeasurement/IdentitySupport (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.9.0): - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleDataTransport (10.1.0): - - nanopb (~> 3.30910.0) - - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.1.0): - - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.1.0): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Privacy - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.1.0)": - - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.1.0) - - GoogleUtilities/Reachability (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - in_app_review (2.0.0): - - Flutter - - nanopb (3.30910.0): - - nanopb/decode (= 3.30910.0) - - nanopb/encode (= 3.30910.0) - - nanopb/decode (3.30910.0) - - nanopb/encode (3.30910.0) - - native_device_orientation (0.0.1): - - Flutter - - package_info_plus (0.4.5): - - Flutter - - PromisesObjC (2.4.0) - - PromisesSwift (2.4.0): - - PromisesObjC (= 2.4.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter - - webview_flutter_wkwebview (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`) - - app_links (from `.symlinks/plugins/app_links/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - - firebase_performance (from `.symlinks/plugins/firebase_performance/ios`) - Flutter (from `Flutter`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - - native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/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/darwin`) - -SPEC REPOS: - trunk: - - Firebase - - FirebaseABTesting - - FirebaseAnalytics - - FirebaseCore - - FirebaseCoreExtension - - FirebaseCoreInternal - - FirebaseCrashlytics - - FirebaseInstallations - - FirebasePerformance - - FirebaseRemoteConfig - - FirebaseRemoteConfigInterop - - FirebaseSessions - - FirebaseSharedSwift - - GoogleAdsOnDeviceConversion - - GoogleAppMeasurement - - GoogleDataTransport - - GoogleUtilities - - nanopb - - PromisesObjC - - PromisesSwift EXTERNAL SOURCES: advanced_in_app_review: :path: ".symlinks/plugins/advanced_in_app_review/ios" - app_links: - :path: ".symlinks/plugins/app_links/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" - firebase_analytics: - :path: ".symlinks/plugins/firebase_analytics/ios" - firebase_core: - :path: ".symlinks/plugins/firebase_core/ios" - firebase_crashlytics: - :path: ".symlinks/plugins/firebase_crashlytics/ios" - firebase_performance: - :path: ".symlinks/plugins/firebase_performance/ios" Flutter: :path: Flutter - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" - in_app_review: - :path: ".symlinks/plugins/in_app_review/ios" - native_device_orientation: - :path: ".symlinks/plugins/native_device_orientation/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" - webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 - app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d - device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 - Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa - firebase_core: 8f0a8c3aca22e29012e419ee18109b4aa021f777 - firebase_crashlytics: 4b21fc111f15e814eeab83f90a71efe3c1e9399a - firebase_performance: 0a30e8afaae656fdf6fa271ac3c62dc26b74d7a1 - FirebaseABTesting: a399ffe546392a39b19a5c2fb28bd8ea178a6f47 - FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 - FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 - FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1 - FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 - FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c - FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa - FirebasePerformance: 94f614453614d8bb2a1a0177f3a1a6d2dbf4c504 - FirebaseRemoteConfig: a2f6545e41551ffb520241d38b5d3d6776c9ebe8 - FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d - FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc - FirebaseSharedSwift: 9d2fa84a46676302b89dbd5e6e62bce2fe376909 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c - GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc - nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - native_device_orientation: 42487b19c552fb5a6a2b51e61b93366f628113df - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e - webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e82e9a80..c83d966a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; BE8095795EAFF58C08A43B48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A68609F67C30CFEF267CD4D3 /* GoogleService-Info.plist */; }; D4F5A60712340001ABCD1234 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -38,6 +40,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -51,6 +54,7 @@ CFF3F6A0DA5A835B219C2D3C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; E751BC99755AAC391DC0B29F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -68,6 +73,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -106,6 +112,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */, ); @@ -134,6 +141,9 @@ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -160,6 +170,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { LastUpgradeCheck = 1510; @@ -246,7 +259,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; + shellScript = "# Symbol upload only matters for builds that ship; Debug dSYMs are absent or empty and fail the tool.\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n echo \"Skipping Crashlytics symbol upload for Debug build\"\n exit 0\nfi\n# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -304,6 +317,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -590,6 +604,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..7baba9ed --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9c12df59..5db441f5 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { GeneratedPluginRegistrant.register(with: self) + guard let cookieExtractorRegistrar = self.registrar( + forPlugin: "CookieExtractorPlugin" + ) else { + assertionFailure("Failed to create CookieExtractorPlugin registrar") + return false + } + CookieExtractorPlugin.register(with: cookieExtractorRegistrar) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/CookieExtractorPlugin.swift b/ios/Runner/CookieExtractorPlugin.swift new file mode 100644 index 00000000..24495865 --- /dev/null +++ b/ios/Runner/CookieExtractorPlugin.swift @@ -0,0 +1,27 @@ +import Flutter +import WebKit + +class CookieExtractorPlugin: NSObject, FlutterPlugin { + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "frosty/cookie_extractor", + binaryMessenger: registrar.messenger() + ) + let instance = CookieExtractorPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "extractTwitchAuthToken" { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + let authCookie = cookies.first { + $0.name == "auth-token" && + ($0.domain == "twitch.tv" || $0.domain.hasSuffix(".twitch.tv")) + } + result(authCookie?.value) + } + } else { + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8aa7eded..6052fdf9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -54,5 +54,9 @@ UIStatusBarHidden + UIBackgroundModes + + audio + diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart new file mode 100644 index 00000000..9d6a5605 --- /dev/null +++ b/lib/apis/twitch_gql_api.dart @@ -0,0 +1,132 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +/// Twitch GQL API client for fetching playback access tokens. +/// +/// Uses the Twitch web client ID (not the app's registered client ID) +/// since the GQL endpoint requires it. +class TwitchGqlApi extends BaseApiClient { + TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); + + /// Browser-consistent User-Agent for playback-related requests. Twitch has + /// rejected access-token requests with non-browser UAs before + /// (streamlink#6574); sending one consistent with the web client ID and + /// web session token avoids the obvious fingerprint mismatch. + static const playbackUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; + + /// Headers for the HLS playlist/segment requests issued by the native + /// player, mirroring what the web player sends to Usher/weaver. + static const playbackHttpHeaders = { + 'User-Agent': playbackUserAgent, + 'Referer': 'https://player.twitch.tv', + 'Origin': 'https://player.twitch.tv', + }; + + /// Fetches a playback access token for the given channel [login]. + /// + /// When [authToken] is provided, the request is authenticated which may + /// grant ad-free playback for subscribers and Turbo users. + /// + /// Uses the web client's persisted GQL query (smaller payload, blends in + /// with regular web traffic); falls back to the inline query template if + /// Twitch ever rotates the persisted-query hash. + Future getPlaybackAccessToken({ + required String login, + String? authToken, + }) async { + final headers = { + 'Client-ID': twitchGqlClientId, + 'User-Agent': playbackUserAgent, + if (authToken != null) 'Authorization': 'OAuth $authToken', + }; + + final variables = { + 'isLive': true, + 'login': login, + 'isVod': false, + 'vodID': '', + 'playerType': 'site', + 'platform': 'web', + }; + + Future query(Map body) => + post('/gql', data: body, headers: headers); + + final response = await query({ + 'operationName': 'PlaybackAccessToken', + 'extensions': { + 'persistedQuery': { + 'version': 1, + // Stable for years; used by the web player and streamlink. + 'sha256Hash': + 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9', + }, + }, + 'variables': variables, + }); + + // PersistedQueryNotFound (or similar) comes back as a 200 with an + // errors array — retry once with the inline template before failing. + if (response['errors'] != null && response['data'] == null) { + debugPrint( + 'TwitchGqlApi: persisted query failed (${response['errors']}), ' + 'falling back to inline template', + ); + return PlaybackAccessToken.fromGqlResponse( + await query({ + 'operationName': 'PlaybackAccessToken_Template', + 'query': + 'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }', + 'variables': variables, + }), + ); + } + + return PlaybackAccessToken.fromGqlResponse(response); + } + + /// Builds the HLS stream URL for the given channel [login] and [token]. + String buildHlsUrl({ + required String login, + required PlaybackAccessToken token, + }) { + final random = Random().nextInt(999999); + final encodedToken = Uri.encodeComponent(token.value); + + // Usher v2 — what the web player and streamlink use today. Same query + // params and token; the multivariant playlist is standards-compliant + // (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO). + return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8' + '?sig=${token.signature}' + '&token=$encodedToken' + '&allow_source=true' + '&allow_audio_only=true' + '&fast_bread=true' + '&supported_codecs=av1,h265,h264' + '&reassignments_supported=true' + '&platform=web' + '&player_backend=mediaplayer' + '&play_session_id=${_randomSessionId()}' + '&playlist_include_framerate=true' + '&p=$random'; + } + + /// 32-char hex play-session id, matching the web client and streamlink. Sent + /// to Usher to identify the playback session; Usher tolerates its absence + /// today but has historically tightened required params. + String _randomSessionId() { + final random = Random(); + final buffer = StringBuffer(); + for (var i = 0; i < 32; i++) { + buffer.write(random.nextInt(16).toRadixString(16)); + } + return buffer.toString(); + } +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 5f3f26cc..bd983fc4 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -14,8 +14,9 @@ class UnauthorizedInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { - // Check if this is a 401 Unauthorized error - if (err.response?.statusCode == 401) { + // Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request + if (err.response?.statusCode == 401 && + _isTwitchHelixOrOAuth(err.requestOptions.uri)) { // For token validation requests, let the error propagate so validateToken can handle it if (err.requestOptions.uri.path.endsWith('/validate')) { handler.next(err); @@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor { } static bool _isDialogShowing = false; + + bool _isTwitchHelixOrOAuth(Uri uri) { + final url = uri.toString(); + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } } diff --git a/lib/constants.dart b/lib/constants.dart index 2edaddba..0b977e2a 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,6 +3,18 @@ import 'package:flutter/material.dart'; /// Twitch API client ID. const clientId = String.fromEnvironment('CLIENT_ID'); +/// Twitch GQL client ID (web player, used for playback access tokens). +const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + +/// SharedPreferences key for the per-channel last-selected stream quality. +/// A saved variant that doesn't exist on the target channel (e.g. 1080p on a +/// source-only channel) falls back to Auto via the quality-list lookup. +String lastStreamQualityKey(String userLogin) => + 'last_stream_quality_$userLogin'; + +/// Legacy global key. Cleared on startup during SharedPreferences init. +const kLegacyLastStreamQualityKey = 'last_stream_quality'; + /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); diff --git a/lib/main.dart b/lib/main.dart index cc428c84..6941eca0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,8 +17,10 @@ import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/firebase_options.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/home.dart'; @@ -52,6 +54,12 @@ void main() async { final prefs = await SharedPreferences.getInstance(); + // One-time migration: the quality preference used to be stored under a + // single global key, which meant a saved 1080p60 from one channel could + // pin another channel to a variant it doesn't serve. Remove the legacy + // key; new writes go under per-channel keys. + await prefs.remove(kLegacyLastStreamQualityKey); + final firstRun = prefs.getBool('first_run') ?? true; // Workaround for clearing stored tokens on uninstall. @@ -104,6 +112,7 @@ void main() async { final bttvApiService = BTTVApi(dioClient); final ffzApiService = FFZApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient); + final twitchGqlApiService = TwitchGqlApi(dioClient); // Create global assets store (shared cache for global emotes/badges) final globalAssetsStore = GlobalAssetsStore( @@ -137,6 +146,7 @@ void main() async { Provider.value(value: bttvApiService), Provider.value(value: ffzApiService), Provider.value(value: sevenTVApiService), + Provider.value(value: twitchGqlApiService), Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), diff --git a/lib/models/irc.dart b/lib/models/irc.dart index c2bea5d2..4f49f28c 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -176,7 +176,9 @@ class IRCMessage { size: badgeSize, color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? - Colors.grey.withValues(alpha: 0.5), + Theme.of(context).colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), ), ), @@ -661,6 +663,8 @@ class IRCMessage { bool showMessage = true, Map? channelIdToUserTwitch, TimestampType timestamp = TimestampType.disabled, + bool forceTimestamp = false, + bool showHistoricalTimestamps = false, String? currentChannelId, }) { final emoteToObject = assetsStore.emoteToObject; @@ -671,7 +675,19 @@ class IRCMessage { // The span list that will be used to render the chat message final span = []; - _addTimestamp(span, style, timestamp); + // When the user has timestamps disabled globally, fall back to the device's + // locale preference for historical messages (opt-in via setting) and for + // forced contexts like previews. + final isHistorical = tags['historical'] == '1'; + final effectiveTimestamp = + (timestamp == TimestampType.disabled && + ((isHistorical && showHistoricalTimestamps) || forceTimestamp)) + ? (MediaQuery.alwaysUse24HourFormatOf(context) + ? TimestampType.twentyFour + : TimestampType.twelve) + : timestamp; + + _addTimestamp(span, style, effectiveTimestamp); _addHistoricalAndChannelBadges( context, span, @@ -680,7 +696,6 @@ class IRCMessage { currentChannelId, ); - final isHistorical = tags['historical'] == '1'; _addUserBadges( context, span, diff --git a/lib/models/playback_access_token.dart b/lib/models/playback_access_token.dart new file mode 100644 index 00000000..8ca16364 --- /dev/null +++ b/lib/models/playback_access_token.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +class PlaybackAccessToken { + final String value; + final String signature; + + /// Whether Twitch will suppress ads for this session (Turbo, sub, etc.). + /// Defaults to false when the token payload can't be parsed. + final bool hideAds; + + /// Quality group names (e.g. "1080p60") that exist for this stream but are + /// restricted to subscribers, so they won't appear in the HLS playlist. + final List restrictedQualities; + + const PlaybackAccessToken({ + required this.value, + required this.signature, + this.hideAds = false, + this.restrictedQualities = const [], + }); + + /// Names in restricted_bitrates that aren't real video qualities + /// (matches streamlink's filter). + static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)'); + + factory PlaybackAccessToken.fromGqlResponse(Map json) { + final data = + (json['data'] as Map?)?['streamPlaybackAccessToken'] + as Map?; + if (data == null) { + throw const FormatException( + 'streamPlaybackAccessToken missing from GQL response', + ); + } + + final value = data['value'] as String; + var hideAds = false; + var restricted = const []; + try { + final payload = jsonDecode(value) as Map; + hideAds = payload['hide_ads'] as bool? ?? false; + final bitrates = + (payload['chansub'] as Map?)?['restricted_bitrates'] + as List?; + if (bitrates != null) { + restricted = bitrates + .whereType() + .where((name) => !_nonQualityNameRe.hasMatch(name)) + .toList(); + } + } catch (e) { + // Token payload format is Twitch-internal — never fail playback over it. + debugPrint('PlaybackAccessToken: failed to parse token payload: $e'); + } + + return PlaybackAccessToken( + value: value, + signature: data['signature'] as String, + hideAds: hideAds, + restrictedQualities: restricted, + ); + } +} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 3d24bb26..d1469988 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,9 +6,13 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/native_video.dart'; +import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/video.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; @@ -43,11 +47,10 @@ class _VideoChatState extends State final _videoKey = GlobalKey(); final _chatKey = GlobalKey(); - // PiP drag state - essential only - double _pipDragDistance = 0; + // PiP drag state — ValueNotifier avoids full widget rebuilds during drag. + final _pipDragDistance = ValueNotifier(0); bool _isPipDragging = false; - bool _isInPipTriggerZone = - false; // Track when in trigger zone for haptic feedback + bool _isInPipTriggerZone = false; // Divider drag state for synchronizing animation bool _isDividerDragging = false; @@ -75,13 +78,24 @@ class _VideoChatState extends State primaryDisplayName: widget.userName, ); - late final VideoStore _videoStore = VideoStore( - userLogin: widget.userLogin, - userId: widget.userId, - twitchApi: context.twitchApi, - authStore: context.authStore, - settingsStore: context.settingsStore, - ); + late final VideoPlayerInterface _videoStore = + context.settingsStore.useNativePlayer + ? NativeVideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + displayName: widget.userName, + twitchApi: context.twitchApi, + twitchGqlApi: context.twitchGqlApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ) + : VideoStore( + userLogin: widget.userLogin, + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + ); @override void initState() { @@ -98,12 +112,10 @@ class _VideoChatState extends State CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); - // Attach listener to the controller (not the derived animation) so it - // survives _springBackAnimation being reassigned in _animateSpringBack. + // Drive the ValueNotifier from the spring-back animation so the + // AnimatedBuilder rebuilds without a full setState. _animationController.addListener(() { - setState(() { - _pipDragDistance = _springBackAnimation.value; - }); + _pipDragDistance.value = _springBackAnimation.value; }); // Register as observer for app lifecycle events @@ -120,58 +132,58 @@ class _VideoChatState extends State } void _handlePipDragStart(DragStartDetails details) { - // Disable drag gesture when already in PiP mode or video is not playing - if (_videoStore.isInPipMode || _videoStore.paused) return; + if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) { + return; + } - _animationController.stop(); // Stop any ongoing animation + _animationController.stop(); + _pipDragDistance.value = 0; + _isInPipTriggerZone = false; setState(() { _isPipDragging = true; - _pipDragDistance = 0; - _isInPipTriggerZone = false; }); } void _handlePipDragUpdate(DragUpdateDetails details) { - if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { return; } - setState(() { - _pipDragDistance += details.delta.dy; - _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance); + // Update the ValueNotifier directly — only the ListenableBuilder rebuilds, + // not the entire channel scaffold. + _pipDragDistance.value = + (_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance); - // Check if we've entered or exited the trigger zone for haptic feedback - final wasInTriggerZone = _isInPipTriggerZone; - _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance; + final wasInTriggerZone = _isInPipTriggerZone; + _isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance; - // Provide haptic feedback when entering the trigger zone - if (!wasInTriggerZone && _isInPipTriggerZone) { - HapticFeedback.mediumImpact(); // Entering trigger zone - } - // Provide subtle haptic feedback when exiting the trigger zone - else if (wasInTriggerZone && !_isInPipTriggerZone) { - HapticFeedback.lightImpact(); // Exiting trigger zone - } - }); + if (!wasInTriggerZone && _isInPipTriggerZone) { + HapticFeedback.mediumImpact(); + } else if (wasInTriggerZone && !_isInPipTriggerZone) { + HapticFeedback.lightImpact(); + } } void _handlePipDragEnd(DragEndDetails details) { - if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + if (!_isPipDragging || + _videoStore.isInPipMode || + _videoStore.paused || + _videoStore.loading) { return; } final velocity = details.velocity.pixelsPerSecond.dy; final shouldTriggerPip = - _pipDragDistance >= _pipTriggerDistance || - velocity > 600; // Simple velocity threshold + _pipDragDistance.value >= _pipTriggerDistance || velocity > 600; if (shouldTriggerPip) { - // Simple haptic feedback on success HapticFeedback.mediumImpact(); _videoStore.requestPictureInPicture(); _resetDragState(); } else { - // Animate back to original position _animateSpringBack(); } } @@ -182,16 +194,16 @@ class _VideoChatState extends State } void _resetDragState() { + _pipDragDistance.value = 0; + _isInPipTriggerZone = false; setState(() { _isPipDragging = false; - _pipDragDistance = 0; - _isInPipTriggerZone = false; }); } void _animateSpringBack() { - _springBackAnimation = Tween(begin: _pipDragDistance, end: 0) - .animate( + _springBackAnimation = + Tween(begin: _pipDragDistance.value, end: 0).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeOut), ); @@ -229,13 +241,13 @@ class _VideoChatState extends State ? AspectRatio(aspectRatio: aspectRatio, child: child) : child; - return AnimatedBuilder( - animation: _animationController, + // Listen to the ValueNotifier (drag) and AnimationController (spring-back) + // so only this subtree rebuilds during the gesture — not the full scaffold. + return ListenableBuilder( + listenable: _pipDragDistance, child: wrappedChild, builder: (context, animatedChild) { - final currentDragDistance = _isPipDragging - ? _pipDragDistance - : _springBackAnimation.value; + final currentDragDistance = _pipDragDistance.value; final scaleFactor = 1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1); @@ -256,11 +268,13 @@ class _VideoChatState extends State if (!_videoStore.isInPipMode) Positioned.fill( child: IgnorePointer( - ignoring: !(_isPipDragging && _pipDragDistance > 0), + ignoring: + !(_isPipDragging && currentDragDistance > 0), child: AnimatedOpacity( - opacity: (_isPipDragging && _pipDragDistance > 0) - ? 1.0 - : 0.0, + opacity: + (_isPipDragging && currentDragDistance > 0) + ? 1.0 + : 0.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Container( @@ -297,7 +311,12 @@ class _VideoChatState extends State final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - child: Video(key: _videoKey, videoStore: _videoStore), + child: _videoStore is NativeVideoStore + ? NativeVideo(nativeVideoStore: _videoStore) + : Video( + key: _videoKey, + videoStore: _videoStore as VideoStore, + ), ); final overlay = GestureDetector( @@ -324,7 +343,9 @@ class _VideoChatState extends State settingsStore: settingsStore, ); - if (_videoStore.paused || _videoStore.streamInfo == null) { + if (_videoStore.paused || + _videoStore.streamInfo == null || + _videoStore.loading) { return videoOverlay; } @@ -344,15 +365,49 @@ class _VideoChatState extends State ), ); - final video = Observer( - builder: (context) { - return Stack( - children: [ - player, - if (_videoStore.settingsStore.showOverlay) overlay, - ], - ); - }, + final video = Stack( + children: [ + player, + Positioned.fill( + child: Observer( + builder: (_) => AnimatedOpacity( + opacity: _videoStore.loading ? 1.0 : 0.0, + // Appear instantly to cover native player placeholders during + // recovery; fade out smoothly when playback resumes. + duration: _videoStore.loading + ? Duration.zero + : const Duration(milliseconds: 300), + curve: Curves.easeOut, + // TickerMode stops the spinner's repeating animation while + // hidden — opacity 0 skips painting but would otherwise keep + // scheduling frames for the whole video session. + child: IgnorePointer( + child: TickerMode( + enabled: _videoStore.loading, + child: const ColoredBox( + color: Colors.black, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white38, + strokeWidth: 2, + ), + ), + ), + ), + ), + ), + ), + ), + ), + Observer( + builder: (_) => _videoStore.settingsStore.showOverlay + ? overlay + : const SizedBox.shrink(), + ), + ], ); final chat = Observer( @@ -683,7 +738,7 @@ class _VideoChatState extends State if (settingsStore.showVideo) ...[ AspectRatio( aspectRatio: 16 / 9, - child: Container(), // Placeholder for video space + child: SizedBox(), ), ], Expanded(child: chat), @@ -710,9 +765,18 @@ class _VideoChatState extends State // If on Android, use PiPSwitcher to enable PiP functionality. if (Platform.isAndroid) { + void notifyPip(bool isInPip) { + final store = _videoStore; + if (store is NativeVideoPlayerInterface) { + store.handleAndroidPipChanged(isInPip); + } + } + return PipWidget( pipLayout: PipActionsLayout.mediaOnlyPause, onPipAction: (_) => _videoStore.handlePausePlay(), + onPipEntered: () => notifyPip(true), + onPipExited: () => notifyPip(false), pipChild: player, child: videoChat, ); @@ -731,6 +795,7 @@ class _VideoChatState extends State _videoStore.dispose(); _animationController.dispose(); + _pipDragDistance.dispose(); SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 0f5f08bb..135940ed 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; @@ -11,6 +12,10 @@ import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/frosty_page_view.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; +/// ~2x default to keep richer message widgets (emotes, badges, replies) built +/// ahead of fast scroll-back without holding the entire history. +const _chatCacheExtent = ScrollCacheExtent.pixels(500.0); + class Chat extends StatelessWidget { final ChatStore chatStore; final EdgeInsetsGeometry? listPadding; @@ -145,6 +150,7 @@ class Chat extends StatelessWidget { ), ), addAutomaticKeepAlives: false, + scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: chatStore.renderMessages.length, itemBuilder: (context, index) => ChatMessage( @@ -172,6 +178,7 @@ class Chat extends StatelessWidget { ), ), addAutomaticKeepAlives: false, + scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, itemBuilder: (context, index) { diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index b43ec476..403381dc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget { style: TextStyle( color: Theme.of( context, - ).colorScheme.onSurfaceVariant, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 4ed851ea..d0f9ed6e 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -15,6 +15,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; import 'package:mobx/mobx.dart'; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; @@ -66,32 +67,41 @@ abstract class ChatStoreBase with Store { 'msg_bad_characters', }; - /// Base height of the bottom bar (input field area). + /// Fallback height used to pad the message list for one frame, until the + /// real bottom bar reports its measured height via [setBottomBarHeight]. static const _baseBottomBarHeight = 68.0; - /// Height of the autocomplete bar (SizedBox + Divider). - static const _autocompleteHeight = 51.0; + /// Server-initiated graceful reconnect notice. Twitch sends this untagged + /// before maintenance so clients can re-establish ahead of the close. + static const _serverReconnectNotice = ':tmi.twitch.tv RECONNECT'; - /// Height of the reply bar (Container + Divider). - static const _replyBarHeight = 41.0; + /// Muted words pre-normalized (trimmed, lowercased, empties dropped) by a + /// reaction on the setting — avoids re-normalizing on every message. + var _normalizedMutedWords = []; - /// Checks if IRC data contains a command that should bypass the chat delay. - /// Returns true if any message in the data contains a bypass command. - bool _shouldBypassDelay(String data) { - for (final message in data.trimRight().split('\r\n')) { - if (message.startsWith('@')) { - final parts = message.split(' '); - if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) { - return true; - } - } - } - return false; + /// Whether a single IRC [line] should bypass the chat delay and be handled + /// immediately. Displayable chat content (PRIVMSG/USERNOTICE and the + /// moderation CLEARCHAT/CLEARMSG) is delayed, so it stays in sync with the + /// delayed video. Everything else is handled at once: control/state messages + /// (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), NOTICE (so a send rejection surfaces + /// immediately instead of one chat-delay later), the reconnect notice, and + /// any untagged server line (PING keepalives, CAP, JOIN, the welcome banner) + /// — delaying those would stall the send-ack, drop the PONG, or never start + /// the buffer drain. + bool _lineShouldBypassDelay(String line) { + if (line.isEmpty || line == _serverReconnectNotice) return true; + // Displayable chat content is always IRCv3-tagged; an untagged line is + // never delayed. + if (!line.startsWith('@')) return true; + final parts = line.split(' '); + return parts.length >= 3 && _delayBypassCommands.contains(parts[2]); } - int get _chatDelaySeconds => settings.effectiveChatDelay.toInt(); + Duration get _chatDelay => Duration( + milliseconds: (settings.effectiveChatDelay * 1000).round(), + ); - bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0; + bool get _hasActiveChatDelay => settings.showVideo && _chatDelay > Duration.zero; final TwitchApi twitchApi; @@ -131,6 +141,12 @@ abstract class ChatStoreBase with Store { static const _maxRetries = 5; + /// Ping interval for both the IRC and 7TV WebSocket sockets. Catches + /// zombie connections (silent network changes, OS suspends) without + /// waking the radio every 15s. Twitch's own recommended keepalive tops + /// out at 600s; community libs (e.g. dank-twitch-irc) sit around 30s. + static const _pingInterval = Duration(seconds: 60); + // The retry counter for exponential backoff. var _retries = 0; @@ -158,6 +174,12 @@ abstract class ChatStoreBase with Store { /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. IRCMessage? toSend; + /// Raw text of the most recent outgoing message, retained until the send is + /// confirmed. Lets the USERSTATE handler build the local echo even when the + /// message was sent before this session's first USERSTATE arrived (so + /// [toSend] couldn't be built up front). + String? _pendingSentMessageText; + /// The list of reaction disposer functions that will be used later when disposing. final reactions = []; @@ -300,25 +322,26 @@ abstract class ChatStoreBase with Store { .toList(); } - /// Current bottom bar height based on visible overlays. - /// Used to dynamically adjust chat message list padding. + /// Measured height of the rendered bottom bar (input plus any reply or + /// autocomplete overlays). The bar reports this via [setBottomBarHeight] so + /// the message list's bottom padding tracks the bar's *true* height — + /// including multi-line input growth and OS text scaling, which fixed + /// per-overlay constants silently undercount. + final _measuredBottomBarHeight = Observable(null); + + /// Current bottom bar height, used to dynamically pad the message list so the + /// bar never overlaps messages. Falls back to the single-line base height + /// until the real bar reports its first measurement. @computed - double get bottomBarHeight { - var height = _baseBottomBarHeight; + double get bottomBarHeight => + _measuredBottomBarHeight.value ?? _baseBottomBarHeight; - // Add reply bar height when replying to a message. - if (replyingToMessage != null) { - height += _replyBarHeight; - } - - // Add autocomplete bar height when showing matches. - if (settings.autocomplete && - ((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) || - (_showMentionAutocomplete && matchingChatters.isNotEmpty))) { - height += _autocompleteHeight; - } - - return height; + /// Reports the bar's real rendered height. Sub-pixel changes are ignored to + /// avoid needless message-list relayouts. + void setBottomBarHeight(double height) { + final current = _measuredBottomBarHeight.value; + if (current != null && (current - height).abs() < 0.5) return; + runInAction(() => _measuredBottomBarHeight.value = height); } ChatStoreBase({ @@ -337,6 +360,21 @@ abstract class ChatStoreBase with Store { reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)), ); + // Normalize muted words once per settings change instead of per message + // — the filter runs in the IRC hot path (50+ msgs/s in busy channels). + reactions.add( + reaction( + (_) => settings.mutedWords.toList(), + (List words) { + _normalizedMutedWords = words + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(); + }, + fireImmediately: true, + ), + ); + reactions.add( reaction( (_) => [ @@ -449,7 +487,7 @@ abstract class ChatStoreBase with Store { ) { queue.add( _DelayedCallback( - releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)), + releaseAt: DateTime.now().add(_chatDelay), callback: callback, ), ); @@ -518,6 +556,15 @@ abstract class ChatStoreBase with Store { // To account for this, split by CRLF, then loop and process each message. for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); + if (message == _serverReconnectNotice) { + // Twitch is about to close the socket for maintenance — close it + // ourselves so the existing onDone path reconnects with no backoff + // (first retry is immediate) instead of waiting for the server to + // drop us mid-message. + _channel?.sink.close(1000); + continue; + } + if (message.startsWith('@')) { // Parse defensively: a single malformed message must never abort the // loop, or one bad frame would drop every other message batched with @@ -549,19 +596,18 @@ abstract class ChatStoreBase with Store { continue; } - // Filter messages containing any muted words. - if (parsedIRCMessage.message != null) { - final List mutedWords = settings.mutedWords; - - // check if the message contains any of the muted words - for (final word in mutedWords) { - if (parsedIRCMessage.message! - .toLowerCase() - .split(settings.matchWholeWord ? ' ' : '') - .contains(word.toLowerCase())) { - return; - } - } + // Filter messages containing any muted words. Must use `continue` + // (not `return`) — a single IRC frame can batch many messages, and + // returning would drop the rest of the frame with the matched one. + if (parsedIRCMessage.message != null && + _normalizedMutedWords.isNotEmpty) { + final msg = parsedIRCMessage.message!.toLowerCase(); + final words = settings.matchWholeWord ? msg.split(' ') : null; + final muted = _normalizedMutedWords.any( + (word) => + words != null ? words.contains(word) : msg.contains(word), + ); + if (muted) continue; } switch (parsedIRCMessage.command) { @@ -577,6 +623,9 @@ abstract class ChatStoreBase with Store { _sendingTimeoutTimer?.cancel(); _isWaitingForAck = false; toSend = null; + // Drop the retained text so a later USERSTATE can't render the + // rejected message as a deferred echo. + _pendingSentMessageText = null; // Show notification with Twitch's rejection message if (parsedIRCMessage.message != null) { @@ -645,23 +694,13 @@ abstract class ChatStoreBase with Store { case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); - // USERSTATE arrival confirms message was sent, even without id tag - if (toSend != null) { - final messageId = parsedIRCMessage.tags['id']; - if (messageId != null) { - toSend!.tags['id'] = messageId; - } else { - // Fallback ID for edge cases where Twitch doesn't provide one - toSend!.tags['id'] = - 'local-${DateTime.now().millisecondsSinceEpoch}'; - } - messageBuffer.add(toSend!); - toSend = null; - // Reset sending state and clear text/reply after successful confirmation - _sendingTimeoutTimer?.cancel(); - _isWaitingForAck = false; - textController.clear(); - replyingToMessage = null; + // A USERSTATE following our PRIVMSG confirms the send. Finalize + // regardless of whether a local echo was pre-built — gating this on + // `toSend` left the player stuck "sending" (and firing a false + // "may not have been sent") whenever the message went out before + // this session's first USERSTATE populated `_userState`. + if (_isWaitingForAck) { + _finalizeSend(parsedIRCMessage.tags['id']); } break; case Command.globalUserState: @@ -825,8 +864,9 @@ abstract class ChatStoreBase with Store { _sevenTVChannel?.sink.close(1000); _clearPendingDelayedCallbacks(clearChat: false); - _sevenTVChannel = WebSocketChannel.connect( + _sevenTVChannel = IOWebSocketChannel.connect( Uri.parse('wss://events.7tv.io/v3'), + pingInterval: _pingInterval, ); void listener(dynamic data) { @@ -913,8 +953,9 @@ abstract class ChatStoreBase with Store { _clearPendingDelayedCallbacks(clearSevenTV: false); _channel?.sink.close(1000); - _channel = WebSocketChannel.connect( + _channel = IOWebSocketChannel.connect( Uri.parse('wss://irc-ws.chat.twitch.tv:443'), + pingInterval: _pingInterval, ); // Only show chat delay countdown on initial connection or video toggle, not on reconnects @@ -927,13 +968,27 @@ abstract class ChatStoreBase with Store { (data) { final dataStr = data.toString(); - // Process immediately if delay is disabled or command should bypass delay - if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) { + // No active delay: process the whole frame immediately. + if (!_hasActiveChatDelay) { _handleIRCData(dataStr); - } else { - _enqueueDelayedCallback(_pendingChatCallbacks, () { - _handleIRCData(dataStr); - }); + return; + } + + // Dispatch each line individually so a bypass-eligible control message + // isn't dragged behind the delay by chat lines batched in the same + // frame — and, conversely, so a delayed chat line can't hold up a + // PING/USERSTATE that shares the frame. + for (final line in dataStr.split('\r\n')) { + final trimmed = line.trimRight(); + if (trimmed.isEmpty) continue; + if (_lineShouldBypassDelay(trimmed)) { + _handleIRCData(trimmed); + } else { + _enqueueDelayedCallback( + _pendingChatCallbacks, + () => _handleIRCData(trimmed), + ); + } } }, onError: (error) { @@ -954,6 +1009,14 @@ abstract class ChatStoreBase with Store { _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; + // Reset send state — any in-flight message is unrecoverable across + // a disconnect, so don't leave the user blocked waiting for an ack + // that will never arrive. + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + toSend = null; + _pendingSentMessageText = null; + if (_shouldDisconnect) { _sevenTVChannel?.sink.close(1000); return; @@ -1124,7 +1187,7 @@ abstract class ChatStoreBase with Store { messageBuffer.clear(); } - var remainingSeconds = _chatDelaySeconds; + var remainingSeconds = _chatDelay.inSeconds; // Create and store reference to the countdown message _countdownMessage = IRCMessage.createNotice( @@ -1209,53 +1272,92 @@ abstract class ChatStoreBase with Store { // Text field and reply state cleared on USERSTATE confirmation (not optimistically) // to preserve message text if Twitch rejects it (e.g., slow mode) - // Start timeout timer in case server doesn't respond (network failure) - // Must be outside userStateString conditional to always have a safety net + // Fallback in case the confirming USERSTATE never arrives. Twitch can omit + // or drop the USERSTATE that acknowledges a send — intermittently, and more + // often under a long chat delay — even though the message was delivered. + // A missing ack is therefore NOT evidence of failure, so rather than cry + // wolf with a "may not have been sent" error (which pushes the user to + // resend and double-post), optimistically finalize the send: render the + // local echo and clear the pending state. Genuine rejections almost always + // arrive first — the rejection NOTICE bypasses the chat delay, so it lands + // well within this window and clears the pending send before the timeout. A + // rejection arriving after the timeout has finalized is treated as sent (the + // echo stays), an accepted edge given how early NOTICEs arrive. Scaled by + // the chat delay so a legitimately delayed USERSTATE is given time to land. _sendingTimeoutTimer?.cancel(); - _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () { - // Guard against execution after disposal - if (_shouldDisconnect) return; - - if (_isWaitingForAck) { - _isWaitingForAck = false; - toSend = null; - HapticFeedback.heavyImpact(); - updateNotification('Message may not have been sent. Please try again.'); - } + _sendingTimeoutTimer = Timer(const Duration(seconds: 10) + _chatDelay, () { + if (_shouldDisconnect || !_isWaitingForAck) return; + runInAction(() => _finalizeSend(null)); }); - // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. - var userStateString = _userState.raw; - if (userStateString != null) { - if (message.length > 3 && message.substring(0, 3) == '/me') { - userStateString += - ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; - } else { - userStateString += - ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; - } + // Build the optimistic local echo from the current USERSTATE. If the + // message is sent before this session's first USERSTATE arrives, + // _buildLocalEcho returns null and the echo is built later, when the + // confirming USERSTATE lands (see the Command.userState handler). The raw + // text is retained so that deferred build is possible. + _pendingSentMessageText = message; + toSend = _buildLocalEcho(message); + } - final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); - if (auth.isLoggedIn && auth.user.details != null) { - userChatMessage.tags['user-id'] = auth.user.details!.id; - } - - if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { - userChatMessage.tags['reply-parent-msg-id'] = - replyingToMessage!.tags['id']!; - userChatMessage.tags['reply-parent-display-name'] = - replyingToMessage!.tags['display-name'] ?? ''; - userChatMessage.tags['reply-parent-msg-body'] = - replyingToMessage!.message ?? ''; - } - - // Ensure a timestamp so merged-mode sorting works correctly. - userChatMessage.tags['tmi-sent-ts'] ??= - '${DateTime.now().millisecondsSinceEpoch}'; - - toSend = userChatMessage; + /// Renders the pending outgoing message's local echo (building it lazily if + /// it couldn't be built at send time) and clears all send-pending state. + /// Shared by the USERSTATE confirmation and the optimistic timeout fallback. + /// [serverMessageId] is the `id` tag from the confirming USERSTATE, or null + /// when finalizing optimistically without one. + void _finalizeSend(String? serverMessageId) { + toSend ??= _pendingSentMessageText != null + ? _buildLocalEcho(_pendingSentMessageText!) + : null; + if (toSend != null) { + // Prefer the server id; fall back to any id already set, then a local one. + toSend!.tags['id'] = serverMessageId ?? + toSend!.tags['id'] ?? + 'local-${DateTime.now().millisecondsSinceEpoch}'; + messageBuffer.add(toSend!); + toSend = null; } + _pendingSentMessageText = null; + _sendingTimeoutTimer?.cancel(); + _isWaitingForAck = false; + textController.clear(); + replyingToMessage = null; + } + + /// Builds the optimistic local echo of an outgoing [message] from the current + /// USERSTATE, or null if no USERSTATE has been received yet this session + /// (the echo can't be rendered without the user's badges/color/display-name). + IRCMessage? _buildLocalEcho(String message) { + var userStateString = _userState.raw; + if (userStateString == null) return null; + + if (message == '/me' || message.startsWith('/me ')) { + userStateString += + ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; + } else { + userStateString += + ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; + } + + final userChatMessage = IRCMessage.fromString(userStateString); + userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); + if (auth.isLoggedIn && auth.user.details != null) { + userChatMessage.tags['user-id'] = auth.user.details!.id; + } + + if (replyingToMessage != null && replyingToMessage!.tags['id'] != null) { + userChatMessage.tags['reply-parent-msg-id'] = + replyingToMessage!.tags['id']!; + userChatMessage.tags['reply-parent-display-name'] = + replyingToMessage!.tags['display-name'] ?? ''; + userChatMessage.tags['reply-parent-msg-body'] = + replyingToMessage!.message ?? ''; + } + + // Ensure a timestamp so merged-mode sorting works correctly. + userChatMessage.tags['tmi-sent-ts'] ??= + '${DateTime.now().millisecondsSinceEpoch}'; + + return userChatMessage; } /// Adds the given [emote] to the chat textfield. diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 8d3f1c8d..c5c2819d 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -55,6 +55,10 @@ class ChatTabInfo { /// The ChatStore for this tab. Null until the tab is activated (lazy loading). ChatStore? chatStore; + /// The id of the most recent message visible the last time the user viewed + /// this tab. Used to compute unread state — null means "no baseline yet". + String? lastSeenMessageId; + /// Whether this is the primary tab (first tab, cannot be removed). final bool isPrimary; @@ -515,10 +519,9 @@ abstract class ChatTabsStoreBase with Store { ), ); - // Switch to the new tab (this will activate it) + // Switch to the new tab, snapshotting the outgoing tab's lastSeenMessageId. final newIndex = _tabs.length - 1; - activateTab(newIndex); - activeTabIndex = newIndex; + setActiveTab(newIndex); // If in merged mode, fetch the new tab's channel profile if (mergedMode) { @@ -639,6 +642,30 @@ abstract class ChatTabsStoreBase with Store { _syncSecondaryTabsToSettings(); } + /// Returns the last message id in [messages] that has a non-null tag id, + /// or null if no such message exists. Skips system messages without ids. + String? _latestMessageId(List messages) { + for (var i = messages.length - 1; i >= 0; i--) { + final id = messages[i].tags['id']; + if (id != null) return id; + } + return null; + } + + /// True if [index] has unread messages since the user last viewed it. + /// Always false for the active tab and in merged mode. + bool hasUnreadMessages(int index) { + if (mergedMode) return false; + if (index == activeTabIndex) return false; + if (index < 0 || index >= _tabs.length) return false; + final tab = _tabs[index]; + final store = tab.chatStore; + if (store == null) return false; + final latest = _latestMessageId(store.messages); + if (latest == null) return false; + return latest != tab.lastSeenMessageId; + } + /// Sets the active tab to the given index. /// /// When [silent] is true, the current tab's draft text and reply state are @@ -647,6 +674,18 @@ abstract class ChatTabsStoreBase with Store { @action void setActiveTab(int index, {bool silent = false}) { if (index >= 0 && index < _tabs.length) { + // Snapshot the outgoing tab's most recent message id so the unread + // dot for that tab clears, and only future messages count as unread. + if (index != activeTabIndex && + activeTabIndex >= 0 && + activeTabIndex < _tabs.length) { + final outgoing = _tabs[activeTabIndex]; + final outStore = outgoing.chatStore; + if (outStore != null) { + outgoing.lastSeenMessageId = _latestMessageId(outStore.messages); + } + } + // Clear text input and emote menu when switching tabs. // Skip in merged mode — tabs act as send-target selectors, not // view switches, so draft/reply state should be preserved. diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 61d4dab7..7f5365f2 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -11,6 +11,7 @@ import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/measure_size.dart'; class ChatBottomBar extends StatelessWidget { final ChatStore chatStore; @@ -393,6 +394,13 @@ class ChatBottomBar extends StatelessWidget { final needsBottomPadding = !chatStore.assetsStore.showEmoteMenu && !isHorizontalLandscape; + // Measured once here; the list pads itself by this height so the bar + // never overlaps messages. Shared by both container branches below. + final measuredBar = MeasureSize( + onChange: (size) => chatStore.setBottomBarHeight(size.height), + child: bottomBarContent, + ); + return isFullscreenOverlay ? Padding( padding: EdgeInsets.only( @@ -400,7 +408,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: bottomBarContent, + child: measuredBar, ) : BlurredContainer( gradientDirection: GradientDirection.down, @@ -409,7 +417,7 @@ class ChatBottomBar extends StatelessWidget { ? MediaQuery.of(context).padding.bottom : 0, ), - child: bottomBarContent, + child: measuredBar, ); }, ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index d7645750..87e313d4 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -17,6 +17,7 @@ class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; final ChatStore chatStore; final bool isModal; + final bool forceTimestamp; final bool showReplyHeader; final bool isInReplyThread; @@ -39,6 +40,7 @@ class ChatMessage extends StatelessWidget { required this.ircMessage, required this.chatStore, this.isModal = false, + this.forceTimestamp = false, this.showReplyHeader = true, this.isInReplyThread = false, this.overrideChannelIdToUserTwitch, @@ -65,6 +67,7 @@ class ChatMessage extends StatelessWidget { username: ircMessage.user!, userId: ircMessage.tags['user-id']!, displayName: ircMessage.tags['display-name']!, + onActivateSourceTab: onActivateSourceTab, ), ); } @@ -84,6 +87,7 @@ class ChatMessage extends StatelessWidget { username: user.login, userId: user.id, displayName: user.displayName, + onActivateSourceTab: onActivateSourceTab, ), ); } @@ -126,7 +130,11 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, @@ -234,7 +242,11 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, - channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, + channelIdToUserTwitch: + overrideChannelIdToUserTwitch ?? chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, @@ -389,9 +401,12 @@ class ChatMessage extends StatelessWidget { }, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? - chatStore.assetsStore.channelIdToUserTwitch, + chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, ), @@ -482,10 +497,14 @@ class ChatMessage extends StatelessWidget { messageHeaderIcon = Icon( Icons.campaign_rounded, size: messageHeaderIconSize, + color: messageHeaderTextColor, ); - messageHeader = const Text( + messageHeader = Text( 'Announcement', - style: TextStyle(fontWeight: messageHeaderFontWeight), + style: TextStyle( + fontWeight: messageHeaderFontWeight, + color: messageHeaderTextColor, + ), ); } @@ -514,9 +533,12 @@ class ChatMessage extends StatelessWidget { badgeScale: chatStore.settings.badgeScale, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + forceTimestamp: forceTimestamp, + showHistoricalTimestamps: + chatStore.settings.showHistoricalTimestamps, channelIdToUserTwitch: overrideChannelIdToUserTwitch ?? - chatStore.assetsStore.channelIdToUserTwitch, + chatStore.assetsStore.channelIdToUserTwitch, currentChannelId: overrideCurrentChannelId ?? chatStore.channelId, ), @@ -589,6 +611,19 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); + final sourceChannelId = + ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id']; + final currentChannelId = + overrideCurrentChannelId ?? chatStore.channelId; + final shouldFade = + chatStore.settings.focusCurrentChannel && + sourceChannelId != null && + sourceChannelId != currentChannelId; + + final fadedMessage = shouldFade + ? Opacity(opacity: 0.55, child: coloredMessage) + : coloredMessage; + final finalMessage = InkWell( onTap: () { FocusScope.of(context).unfocus(); @@ -597,7 +632,7 @@ class ChatMessage extends StatelessWidget { } }, onLongPress: () => onLongPressMessage(context, defaultTextStyle), - child: coloredMessage, + child: fadedMessage, ); return finalMessage; diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index fe946202..724564aa 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -38,7 +38,6 @@ class ChatTabs extends StatelessWidget { ); if (!added && context.mounted) { - // If not added, it means the channel already exists (switched to it) ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Channel already open, switched to it'), @@ -56,16 +55,10 @@ class ChatTabs extends StatelessWidget { final tabs = chatTabsStore.tabs; final activeIndex = chatTabsStore.activeTabIndex; final showTabBar = chatTabsStore.showTabBar; - final showMerge = - tabs.where((t) => t.isActivated).length >= 2; + final showMerge = tabs.where((t) => t.isActivated).length >= 2; - // Calculate extra top padding for tab bar when visible final tabBarHeight = showTabBar ? 48.0 : 0.0; - - // Get the top inset from listPadding (e.g., for AppBar in chat-only mode) final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; - - // Adjust list padding to account for tab bar final adjustedPadding = listPadding != null ? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) : EdgeInsets.only(top: tabBarHeight); @@ -75,27 +68,20 @@ class ChatTabs extends StatelessWidget { onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // On Android back gesture, close overlays first before navigating back + // Android back gesture: dismiss overlays before navigating away. final activeStore = chatTabsStore.activeChatStore; - - // Priority 1: Close emote menu if (activeStore.assetsStore.showEmoteMenu) { activeStore.assetsStore.showEmoteMenu = false; return; } - - // Priority 2: Unfocus keyboard if (activeStore.textFieldFocusNode.hasFocus) { activeStore.unfocusInput(); return; } - - // Priority 3: Navigate back Navigator.of(context).pop(); }, child: Stack( children: [ - // Chat content: merged view or IndexedStack Positioned.fill( child: chatTabsStore.mergedMode ? Chat( @@ -107,7 +93,6 @@ class ChatTabs extends StatelessWidget { : IndexedStack( index: activeIndex, children: tabs.map((tabInfo) { - // Show placeholder for non-activated tabs if (tabInfo.chatStore == null) { return const SizedBox.shrink(); } @@ -120,7 +105,6 @@ class ChatTabs extends StatelessWidget { }).toList(), ), ), - // Tab bar (only visible when more than 1 tab) if (showTabBar) Positioned( top: topInset, @@ -130,8 +114,8 @@ class ChatTabs extends StatelessWidget { height: 48, child: Stack( children: [ - // When merge button is visible, clips at its center - // so chips slide under its left half then disappear. + // Right-clip past the merge button so chips slide + // under its left half then disappear. Positioned( left: 0, top: 0, @@ -161,7 +145,7 @@ class ChatTabs extends StatelessWidget { padding: EdgeInsets.only( right: index < tabs.length - 1 ? 4 : 0, ), - child: _buildTab(context, index), + child: Center(child: _buildTab(context, index)), ); }, ), @@ -171,7 +155,9 @@ class ChatTabs extends StatelessWidget { right: 12, top: 0, bottom: 0, - child: Center(child: _buildMergeToggle(context)), + child: Center( + child: _buildMoreActionsMenu(context), + ), ), ], ), @@ -184,22 +170,47 @@ class ChatTabs extends StatelessWidget { ); } - Widget _buildMergeToggle(BuildContext context) { - final isMerged = chatTabsStore.mergedMode; - return IconButton.filledTonal( - icon: const Icon(Icons.call_merge, size: 18), - tooltip: isMerged ? 'Split chats' : 'Merge loaded chats', - visualDensity: VisualDensity.compact, - isSelected: isMerged, - style: IconButton.styleFrom( - minimumSize: const Size(42, 42), - backgroundColor: isMerged - ? null - : Theme.of(context).colorScheme.surfaceContainerHighest, - ), - onPressed: () { - HapticFeedback.selectionClick(); - chatTabsStore.toggleMergedMode(); + Widget _buildMoreActionsMenu(BuildContext context) { + final settings = chatTabsStore.activeChatStore.settings; + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + Observer( + builder: (_) => CheckboxMenuButton( + value: chatTabsStore.mergedMode, + onChanged: (_) { + HapticFeedback.selectionClick(); + chatTabsStore.toggleMergedMode(); + }, + child: const Text('Merge chats'), + ), + ), + Observer( + builder: (_) => CheckboxMenuButton( + value: settings.focusCurrentChannel, + onChanged: (newValue) { + HapticFeedback.selectionClick(); + settings.focusCurrentChannel = newValue ?? false; + }, + child: const Text('Focus current channel'), + ), + ), + ], + anchorBuilder: (context, toggle) { + return IconButton.filledTonal( + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Chat options', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + minimumSize: const Size(42, 42), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + onPressed: () { + HapticFeedback.selectionClick(); + toggle(); + }, + ); }, ); } @@ -207,86 +218,208 @@ class ChatTabs extends StatelessWidget { Widget _buildTab(BuildContext context, int index) { final tabInfo = chatTabsStore.tabs[index]; final isActive = index == chatTabsStore.activeTabIndex; - final isActivated = tabInfo.isActivated; + final isSecondary = !tabInfo.isPrimary; final displayName = getReadableName( tabInfo.displayName, tabInfo.channelLogin, ); - final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12); + return Observer( + builder: (context) { + final theme = Theme.of(context); + final isActivated = tabInfo.isActivated; + final hasUnread = chatTabsStore.hasUnreadMessages(index); + final dimmed = !isActivated || !(tabInfo.chatStore?.isConnected ?? false); - if (tabInfo.isPrimary) { - return InputChip( - avatar: avatar, - label: Text(displayName), - selected: isActive, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - if (!isActive) { - HapticFeedback.selectionClick(); - chatTabsStore.setActiveTab(index); - } - }, - ); - } - - return MenuAnchor( - menuChildren: [ - if (isActivated) - MenuItemButton( - leadingIcon: const Icon(Icons.power_off_rounded, size: 18), - child: const Text('Disconnect'), - onPressed: () { - HapticFeedback.lightImpact(); - chatTabsStore.deactivateTab(index); - }, - ), - MenuItemButton( - leadingIcon: Icon( - Icons.delete_outline_rounded, - size: 18, - color: Theme.of(context).colorScheme.error, - ), - child: Text( - 'Remove', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - onPressed: () { - HapticFeedback.lightImpact(); - chatTabsStore.removeTab(index); - }, - ), - ], - builder: (context, controller, child) { - return InputChip( - avatar: - isActivated ? avatar : Opacity(opacity: 0.5, child: avatar), - label: Text( - displayName, - style: isActivated - ? null - : TextStyle( - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + InputChip buildChip({VoidCallback? onDeleted}) => InputChip( + avatar: Badge( + smallSize: 8, + backgroundColor: theme.colorScheme.primary, + isLabelVisible: hasUnread, + child: Opacity( + opacity: dimmed ? 0.5 : 1.0, + child: ProfilePicture( + userLogin: tabInfo.channelLogin, + radius: 12, ), - ), - selected: isActive, - showCheckmark: false, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () { - if (!isActive) { - HapticFeedback.selectionClick(); - chatTabsStore.setActiveTab(index); - } - }, - onDeleted: () => controller.open(), - deleteButtonTooltipMessage: 'Tab options', + ), + ), + label: Text( + displayName, + style: dimmed + ? TextStyle( + color: theme.textTheme.bodyMedium?.color + ?.withValues(alpha: 0.5), + ) + : null, + ), + selected: isActive, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + if (isActive) return; + HapticFeedback.selectionClick(); + chatTabsStore.setActiveTab(index); + }, + onDeleted: onDeleted, + deleteButtonTooltipMessage: + onDeleted != null ? 'Tab options' : null, + ); + + if (!isSecondary) return buildChip(); + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + if (isActivated) + MenuItemButton( + leadingIcon: const Icon(Icons.power_off_rounded, size: 18), + child: const Text('Disconnect'), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + chatTabsStore.deactivateTab(index); + }, + ), + MenuItemButton( + leadingIcon: Icon( + Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.error, + ), + child: Text( + 'Remove', + style: TextStyle(color: theme.colorScheme.error), + ), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + chatTabsStore.removeTab(index); + }, + ), + ], + anchorBuilder: (context, toggle) => + buildChip(onDeleted: toggle), ); }, ); } } + +/// Lightweight popup menu anchored to a child widget. The menu's top-right +/// aligns to the anchor's bottom-right via [CompositedTransformFollower] and +/// fades + scales in from that corner. [MenuAnchor] doesn't expose an entry +/// animation hook in this Flutter version, so we drive one ourselves. +class _AnchoredPopupMenu extends StatefulWidget { + final Widget Function(BuildContext context, VoidCallback toggle) + anchorBuilder; + final List Function(VoidCallback close) itemsBuilder; + + const _AnchoredPopupMenu({ + required this.anchorBuilder, + required this.itemsBuilder, + }); + + @override + State<_AnchoredPopupMenu> createState() => _AnchoredPopupMenuState(); +} + +class _AnchoredPopupMenuState extends State<_AnchoredPopupMenu> + with SingleTickerProviderStateMixin { + final _link = LayerLink(); + final _portalCtrl = OverlayPortalController(); + late final _anim = AnimationController( + duration: const Duration(milliseconds: 150), + reverseDuration: const Duration(milliseconds: 100), + vsync: this, + )..addStatusListener((status) { + if (status == AnimationStatus.dismissed && _portalCtrl.isShowing) { + _portalCtrl.hide(); + } + }); + late final _curve = + CurvedAnimation(parent: _anim, curve: Curves.easeOut); + late final _scale = Tween(begin: 0.92, end: 1.0).animate(_curve); + + @override + void dispose() { + _curve.dispose(); + _anim.dispose(); + super.dispose(); + } + + void _toggle() { + if (_portalCtrl.isShowing) { + _anim.reverse(); + } else { + _portalCtrl.show(); + _anim.forward(); + } + } + + void _close() { + if (_portalCtrl.isShowing) _anim.reverse(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _portalCtrl, + overlayChildBuilder: (overlayContext) { + final theme = Theme.of(overlayContext); + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + CompositedTransformFollower( + link: _link, + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + offset: const Offset(0, 4), + showWhenUnlinked: false, + child: FadeTransition( + opacity: _anim, + child: ScaleTransition( + scale: _scale, + alignment: Alignment.topRight, + child: Material( + color: theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(12), + ), + side: BorderSide( + color: theme.colorScheme.outlineVariant + .withValues(alpha: 0.5), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.itemsBuilder(_close), + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + child: widget.anchorBuilder(context, _toggle), + ), + ); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 2e0700ae..07c975a5 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -14,6 +14,7 @@ class ChatUserModal extends StatefulWidget { final String username; final String displayName; final String userId; + final VoidCallback? onActivateSourceTab; const ChatUserModal({ super.key, @@ -21,6 +22,7 @@ class ChatUserModal extends StatefulWidget { required this.username, required this.displayName, required this.userId, + this.onActivateSourceTab, }); @override @@ -61,6 +63,7 @@ class _ChatUserModalState extends State { IconButton( tooltip: 'Reply', onPressed: () { + widget.onActivateSourceTab?.call(); widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); @@ -115,6 +118,7 @@ class _ChatUserModalState extends State { ircMessage: userMessages[index], chatStore: widget.chatStore, isModal: true, + forceTimestamp: true, ), ), ), diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 734408b3..7dc83394 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget { padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Text( '$replyName: $replyBody', - style: const TextStyle(fontWeight: FontWeight.w500), + style: const TextStyle(fontWeight: FontWeight.w600), ), ), Flexible( diff --git a/lib/screens/channel/video/chat_latency_sync.dart b/lib/screens/channel/video/chat_latency_sync.dart new file mode 100644 index 00000000..0b5ff544 --- /dev/null +++ b/lib/screens/channel/video/chat_latency_sync.dart @@ -0,0 +1,32 @@ +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +/// Single owner of `SettingsStore.syncedChatDelay` writes from the video +/// player stores. Previously both stores poked the setting directly, +/// making settings an ad-hoc event bus between video and chat. +class ChatLatencySync { + ChatLatencySync({required this.settingsStore}); + + final SettingsStore settingsStore; + + /// Reset the delay to zero on stream transitions (channel enter, stream + /// end, chat-only toggle, etc). No-op when auto-sync is disabled. + void reset() { + if (settingsStore.autoSyncChatDelay) { + settingsStore.syncedChatDelay = 0.0; + } + } + + /// Push a fresh video-latency measurement. Only writes when drift from + /// the current value exceeds the tolerance, to avoid thrashing the chat + /// delay countdown on minor fluctuations. + void report(double latencySeconds) { + if (!settingsStore.autoSyncChatDelay) return; + final current = settingsStore.syncedChatDelay; + if (current == 0 || + (latencySeconds - current).abs() > + VideoTimingConstants.chatSyncDriftToleranceSeconds) { + settingsStore.syncedChatDelay = latencySeconds; + } + } +} diff --git a/lib/screens/channel/video/native_video.dart b/lib/screens/channel/video/native_video.dart new file mode 100644 index 00000000..78c51f52 --- /dev/null +++ b/lib/screens/channel/video/native_video.dart @@ -0,0 +1,106 @@ +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; + +/// Native video player widget backed by AVPlayerViewController (iOS) +/// and ExoPlayer (Android). +class NativeVideo extends StatelessWidget { + final NativeVideoStore nativeVideoStore; + + const NativeVideo({super.key, required this.nativeVideoStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final controller = nativeVideoStore.controller; + final error = nativeVideoStore.error; + + final isOffline = + !nativeVideoStore.loading && + nativeVideoStore.streamInfo == null && + error == null; + + return ColoredBox( + color: Colors.black, + child: Stack( + children: [ + if (controller != null && !isOffline) + NativeVideoPlayer( + key: ObjectKey(controller), + controller: controller, + ), + if (controller != null && !nativeVideoStore.hasPlayedOnce && !isOffline) + const ColoredBox( + color: Colors.black, + child: SizedBox.expand(), + ), + if (error != null) + Align( + alignment: const Alignment(0, 0.3), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + color: Colors.amber.withValues(alpha: 0.7), + size: 32, + ), + const SizedBox(height: 10), + Text( + error, + style: const TextStyle( + color: Colors.white60, + fontSize: 13, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + if (isOffline) + const Center( + child: Icon( + Icons.tv_off_rounded, + color: Colors.white24, + size: 32, + ), + ), + if (nativeVideoStore.isAudioOnlyMode && !isOffline) + const ColoredBox( + color: Colors.black, + child: SizedBox.expand( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.headphones_rounded, + color: Colors.white24, + size: 32, + ), + SizedBox(height: 8), + Text( + 'Audio Only', + style: TextStyle( + color: Colors.white24, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/native_video_player_interface.dart b/lib/screens/channel/video/native_video_player_interface.dart new file mode 100644 index 00000000..766d5a3c --- /dev/null +++ b/lib/screens/channel/video/native_video_player_interface.dart @@ -0,0 +1,12 @@ +import 'package:frosty/screens/channel/video/video_player_interface.dart'; + +/// Capabilities that only the native player can meaningfully support. +/// Callers that need these must type-check against this interface. +abstract class NativeVideoPlayerInterface extends VideoPlayerInterface { + /// Whether the current quality is an HLS audio-only variant. + bool get isAudioOnlyMode; + + /// Called from platform PiP callbacks on Android. Lets the store sync + /// its overlay state with the OS PiP window. + void handleAndroidPipChanged(bool isInPip); +} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart new file mode 100644 index 00000000..e0b789bc --- /dev/null +++ b/lib/screens/channel/video/native_video_store.dart @@ -0,0 +1,1308 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:better_native_video_player/better_native_video_player.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/playback_access_token.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; +import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; +import 'package:frosty/screens/channel/video/stream_info_poller.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:simple_pip_mode/simple_pip.dart'; + +part 'native_video_store.g.dart'; + +class NativeVideoStore = NativeVideoStoreBase with _$NativeVideoStore; + +abstract class NativeVideoStoreBase + with Store + implements NativeVideoPlayerInterface { + static int _nextId = 0; + + @override + final String userLogin; + final String userId; + final String displayName; + final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; + final AuthStore authStore; + + @override + final SettingsStore settingsStore; + + @readonly + NativeVideoPlayerController? _controller; + + Timer? _overlayTimer; + Timer? _latencyTimer; + Timer? _stallRecoveryTimer; + Timer? _streamInfoTimer; + var _stallRecoveryAttempt = 0; + var _isStalled = false; + Timer? _initRetryTimer; + Timer? _healthyPlaybackTimer; + DateTime? _lastRefreshTime; + bool _overlayWasVisibleBeforePip = true; + var _userPaused = false; + + String? _profileImageUrl; + var _manualPipRequested = false; + var _lastPipWasAutomatic = false; + List _qualityObjects = []; + var _firstTimeSettingQuality = true; + int? _pendingQualityIndex; + var _disposed = false; + var _initializing = true; + var _initInFlight = false; + var _controllerInitialized = false; + var _totalRefreshAttempts = 0; + var _highLatencyCount = 0; + + /// Timestamp of the most recent transition into `_onPlaying`. A subsequent + /// stall that arrives inside [VideoTimingConstants.shortPlayWindow] counts + /// as a bounce; repeated bounces surface a tailored CDN-trouble error. + DateTime? _lastPlayingStart; + int _shortPlayBounceCount = 0; + var _isQualitySwitching = false; + Timer? _qualitySwitchGraceTimer; + + /// Set when an automatic refresh was suppressed because the user had + /// paused (e.g. the playback session expired mid-pause). Consumed by + /// [handlePausePlay] to refresh instead of playing a dead session. + var _pendingRefreshOnResume = false; + var _initGeneration = 0; + static final _bareResolutionRe = RegExp(r'^(\d+)p$'); + + final _pip = SimplePip(); + late final ChatLatencySync _chatLatencySync = + ChatLatencySync(settingsStore: settingsStore); + late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller( + twitchApi: twitchApi, + userLogin: userLogin, + userId: userId, + ); + ReactionDisposer? _disposeAndroidAutoPipReaction; + ReactionDisposer? _disposeVideoModeReaction; + + /// Cached SimplePip.isAutoPipAvailable result. Populated by the Android + /// auto-PiP reaction on first run; reused by dispose to avoid a redundant + /// platform-channel call. + bool? _autoPipAvailable; + + StreamSubscription? _pipSub; + StreamSubscription>? _qualitiesSub; + StreamSubscription? _adBreakSub; + + /// True while a server-stitched ad break is playing (native player detects + /// twitch-stitched-ad dateranges). Latency readings and play/buffer + /// transitions are unreliable during ads, so recovery and chat sync are + /// suspended rather than burning refresh attempts on ad-induced noise. + var _isAdBreakActive = false; + + @readonly + var _loading = true; + + @readonly + var _paused = true; + + @readonly + var _hasPlayedOnce = false; + + @readonly + var _overlayVisible = true; + + @readonly + StreamTwitch? _streamInfo; + + @readonly + Channel? _offlineChannelInfo; + + @readonly + var _availableStreamQualities = []; + + /// Qualities that exist for this stream but require a subscription + /// (parsed from the playback token's restricted_bitrates). + @readonly + var _restrictedStreamQualities = []; + + @readonly + var _streamQualityIndex = 0; + + @override + @computed + String get streamQuality => + _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; + + @readonly + String? _latency; + + @readonly + var _isInPipMode = false; + + @readonly + var _isAudioOnlyMode = false; + + @readonly + String? _error; + + NativeVideoStoreBase({ + required this.userLogin, + required this.userId, + required this.displayName, + required this.twitchApi, + required this.twitchGqlApi, + required this.authStore, + required this.settingsStore, + }) { + _chatLatencySync.reset(); + _scheduleOverlayHide(); + updateStreamInfo(); + _startStreamInfoTimer(); + + // Skip eager init in chat-only mode — `play()` is what activates the + // iOS audio session, so without it nothing is allocated. + if (settingsStore.showVideo) { + _controller = _createController(); + _initPlayer(); + } else { + _loading = false; + _initializing = false; + } + + // `pause()` alone leaves the audio session, PiP controller, and URL + // session alive on iOS, which blocks low-power sleep states. Full + // dispose is the only path that deactivates the audio session. + _disposeVideoModeReaction = reaction( + (_) => settingsStore.showVideo, + (showVideo) { + if (!showVideo) { + _disposeController(); + _resetPlayerStateForChatOnly(); + } else if (_controller == null) { + _initFreshController(); + } + }, + ); + + if (Platform.isAndroid) { + // Explicit reaction on showVideo only (vs autorun, which re-runs on any + // observable the closure happens to read). Caches the auto-PiP + // availability check — it doesn't change within a session. + _disposeAndroidAutoPipReaction = reaction( + (_) => settingsStore.showVideo, + (showVideo) async { + _autoPipAvailable ??= await SimplePip.isAutoPipAvailable; + if (_disposed || !_autoPipAvailable!) return; + if (showVideo) { + _pip.setAutoPipMode(); + } else { + _pip.setAutoPipMode(autoEnter: false); + } + }, + fireImmediately: true, + ); + } + } + + NativeVideoPlayerController _createController() { + final controller = NativeVideoPlayerController( + id: _nextId++, + autoPlay: true, + showNativeControls: false, + mediaInfo: NativeVideoPlayerMediaInfo( + title: displayName, + subtitle: 'Frosty — Twitch', + artworkUrl: _profileImageUrl, + ), + ); + controller.addActivityListener(_onPlayerActivity); + return controller; + } + + /// Pushes the channel's Now Playing metadata (lock screen / control center) + /// to the native player using the cached profile image. + void _applyNowPlayingMediaInfo() { + if (_disposed || _controller == null) return; + _controller!.setMediaInfo(NativeVideoPlayerMediaInfo( + title: displayName, + subtitle: _streamInfo?.title ?? 'Twitch', + artworkUrl: _profileImageUrl, + )); + } + + @action + Future _initPlayer() async { + if (_initInFlight) return; + _initInFlight = true; + final generation = _initGeneration; + try { + // Fetch profile image URL for Now Playing artwork (fire-and-forget). + // _initPlayer re-runs on every hard refresh and chat-only toggle, so + // skip the Helix call once the URL is cached. + if (_profileImageUrl != null) { + _applyNowPlayingMediaInfo(); + } else { + twitchApi.getUser(id: userId).then((user) { + _profileImageUrl = user.profileImageUrl; + _applyNowPlayingMediaInfo(); + }).catchError((e) { + debugPrint('NativeVideoStore: profile image fetch failed: $e'); + }); + } + + await _controller!.initialize(); + if (_disposed || generation != _initGeneration) { + // Don't reset _initInFlight here — a newer generation's _initPlayer + // may already own the flag. Only the current generation should touch it. + return; + } + + _adBreakSub = _controller!.adBreakStream.listen((isActive) { + _isAdBreakActive = isActive; + debugPrint( + 'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}', + ); + }); + + _pipSub = _controller!.isPipEnabledStream.listen((isPip) { + runInAction(() { + // iOS-only bookkeeping: track whether PiP was triggered by gesture + // (auto) or button (manual). Must run before _setPipActive mutates + // _isInPipMode. + if (isPip && !_isInPipMode) { + _lastPipWasAutomatic = !_manualPipRequested; + _manualPipRequested = false; + } else if (!isPip && _isInPipMode) { + _lastPipWasAutomatic = false; + } + _setPipActive(isPip); + }); + }); + + _qualitiesSub = _controller!.qualitiesStream.listen((qualities) { + runInAction(() { + // Filter out the plugin's auto entry (we add our own) and deduplicate. + final seen = {}; + final filtered = qualities + .where((q) => !q.isAuto && seen.add(q.label)) + .toList(); + + // Remove bare "{height}p" entries when a fps-suffixed version + // (e.g. "160p30") exists at the same height. The bare entry is + // typically a duplicate HLS variant with missing FRAME-RATE that + // causes frozen video when selected. + final labels = filtered.map((q) => q.label).toSet(); + filtered.removeWhere((q) { + final bare = _bareResolutionRe.firstMatch(q.label); + if (bare == null) return false; + final height = bare.group(1)!; + return labels.any( + (l) => l != q.label && l.startsWith('${height}p'), + ); + }); + + // Reverse so highest quality comes first. + _qualityObjects = filtered.reversed.toList(); + _availableStreamQualities = [ + 'Auto', + ..._qualityObjects.map((q) => q.label), + ]; + + if (_firstTimeSettingQuality && qualities.isNotEmpty) { + _firstTimeSettingQuality = false; + if (settingsStore.defaultToHighestQuality) { + _queueOrApplyInitialQualityIndex(1); + } else { + SharedPreferences.getInstance().then((prefs) { + if (_disposed || _pendingQualityIndex != null) return; + final lastQuality = + prefs.getString(lastStreamQualityKey(userLogin)); + if (lastQuality == null) return; + final index = _availableStreamQualities.indexOf(lastQuality); + if (index != -1) { + runInAction(() => _queueOrApplyInitialQualityIndex(index)); + } + }).catchError((e) { + debugPrint('NativeVideoStore: failed to read last quality: $e'); + }); + } + } + }); + }); + + _controllerInitialized = true; + } catch (e, stackTrace) { + if (_disposed || generation != _initGeneration) return; + _initInFlight = false; + await _handleLoadError(e, stackTrace, generation); + return; + } + _initInFlight = false; + await _runLoadStream(generation); + } + + /// Wraps `_loadStream` with shared success/error handling. On success, + /// clears `_error`; on failure, delegates to `_handleLoadError` for retry + /// scheduling or error display. Used by both first-time init and refresh. + Future _runLoadStream(int generation) async { + try { + await _loadStream(generation); + if (_disposed || generation != _initGeneration) return; + runInAction(() { + _error = null; + }); + } catch (e, stackTrace) { + if (_disposed || generation != _initGeneration) return; + await _handleLoadError(e, stackTrace, generation); + } + } + + /// Fetches a fresh playback token, builds the HLS URL, and loads it into + /// the (already-initialized) controller. Used by both first-time init and + /// user-initiated refresh (light refresh reuses the same AVPlayer via + /// replaceCurrentItem internally, per Apple's HLS recovery guidance). + Future _loadStream(int generation) async { + // Use web cookie token (works with web Client-ID) for ad-free playback. + final authToken = authStore.gqlToken; + late final PlaybackAccessToken token; + try { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + } catch (e) { + if (authToken != null) { + debugPrint('NativeVideoStore: auth token failed, retrying without: $e'); + if (e is UnauthorizedException) { + // Twitch rejected the web session token — unlink it so the UI + // reflects the true state and future loads skip this request. + authStore.invalidateGqlToken(); + } + token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + } else { + rethrow; + } + } + + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + runInAction( + () => _restrictedStreamQualities = token.restrictedQualities, + ); + final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + + await _controller!.loadUrl( + url: hlsUrl, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { + return; + } + await _controller!.configureForLivePlayback(); + _startLatencyPolling(); + } + + /// Shared error handler for both `_initPlayer` and `_reloadCurrentController`. + /// Checks stream liveness, auto-retries if CDN is still serving stale + /// manifests, or surfaces the error with the overlay visible. + Future _handleLoadError( + Object e, + StackTrace stackTrace, + int generation, + ) async { + _initializing = false; + // Wait for stream info so we can distinguish "offline" from "broken". + await updateStreamInfo(forceUpdate: true); + if (_disposed || generation != _initGeneration) return; + debugPrint('NativeVideoStore load error: $e'); + if (_streamInfo != null) { + _recordNativePlaybackError( + e, + stackTrace, + 'Native video load failed', + information: [ + 'channel=$userLogin', + 'attempt=$_totalRefreshAttempts', + ], + ); + } + + // Auto-retry if the stream is live — the CDN may still be serving + // stale manifests right after a stream crash. + if (_streamInfo != null && + _totalRefreshAttempts < VideoTimingConstants.maxRefreshAttempts) { + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: auto-retrying load ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _initRetryTimer?.cancel(); + _initRetryTimer = Timer(VideoTimingConstants.initRetryDelay, () { + if (!_disposed && generation == _initGeneration) { + _handleRefresh(userInitiated: false); + } + }); + return; + } + + runInAction(() { + _loading = false; + _paused = true; + // Only show the error if the stream is actually live — an offline + // channel is expected to fail here and the overlay handles it. + if (_streamInfo != null) { + _showErrorOverlay( + 'Native player failed to load. Try the standard player in Settings.', + ); + } + }); + } + + /// Light refresh — reloads the stream URL on the existing controller. + /// Skips platform view recreation, audio session churn, and subscription + /// teardown. Per Apple's WWDC 2017 guidance, this is the preferred + /// pattern: new AVPlayerItem via `replaceCurrentItem` on existing AVPlayer. + @action + Future _reloadCurrentController() => _runLoadStream(_initGeneration); + + void _onPlayerActivity(PlayerActivityEvent event) { + runInAction(() { + switch (event.state) { + case PlayerActivityState.playing: + _onPlaying(); + case PlayerActivityState.buffering: + case PlayerActivityState.loading: + _onBufferingOrLoading(event.state); + case PlayerActivityState.error: + _onPlayerError(event.data); + case PlayerActivityState.paused: + case PlayerActivityState.stopped: + case PlayerActivityState.completed: + case PlayerActivityState.idle: + _onPaused(); + default: + break; + } + }); + } + + void _onPlaying() { + _loading = false; + _paused = false; + _isStalled = false; + _hasPlayedOnce = true; + _initializing = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + _stallRecoveryAttempt = 0; + _lastPlayingStart = DateTime.now(); + _scheduleHealthyPlaybackRecoveryReset(_lastPlayingStart!); + if (_pendingQualityIndex != null) { + final index = _pendingQualityIndex!; + _pendingQualityIndex = null; + _setStreamQualityIndex(index); + } + _updateLatency(); + } + + void _onBufferingOrLoading(PlayerActivityState state) { + if (!_isQualitySwitching && !_hasPlayedOnce) { + _loading = true; + } + // Track mid-stream stalls separately from initial loading. With + // automaticallyWaitsToMinimizeStalling = false, AVPlayer silently drops + // rate to 0 when the buffer empties. The stall timer needs this flag + // to engage recovery on those stalls. + if (_hasPlayedOnce && !_isQualitySwitching) { + _isStalled = true; + // Ad-pod entry/exit produces short play/buffer bounces that would + // otherwise count toward the CDN-trouble error heuristic. + if (!_isAdBreakActive) _recordShortPlayBounceIfNeeded(); + } + // Clear _initializing on the first buffering event so the stall + // recovery timer isn't permanently blocked during init stalls. + if (_initializing && state == PlayerActivityState.buffering) { + _initializing = false; + } + if (!_userPaused && !_initializing) _startStallRecoveryTimer(); + } + + void _onPlayerError(Map? eventData) { + _loading = false; + _paused = true; + _isStalled = false; + _initializing = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + final errorMessage = + eventData?['message'] as String? ?? + 'Playback error. Try refreshing or switch to the standard player in Settings.'; + final errorCode = eventData?['code'] as String?; + final statusCode = eventData?['statusCode'] as int?; + + if (errorCode == 'mediaServicesWereReset') { + _handleMediaServicesReset(errorMessage); + return; + } + + // 4xx on the HLS fetch means the current playback session is gone — a + // fresh GQL token + new query params can land on a different CDN edge + // with a working manifest. Skip the user-facing error and retry + // immediately (handleRefresh updates _lastRefreshTime itself). + if (statusCode != null && + statusCode >= 400 && + statusCode < 500 && + !_refreshCapReached()) { + if (_userPaused) { + // Don't yank a user-paused stream back to life — the refresh would + // reload with autoplay. Defer until they resume. + _pendingRefreshOnResume = true; + return; + } + debugPrint( + 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' + 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + ); + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); + return; + } + + // Check stream liveness before showing the error — an offline channel + // is expected to 404 and the overlay handles it. + final generation = _initGeneration; + updateStreamInfo(forceUpdate: true).then((_) { + if (_disposed || generation != _initGeneration) return; + runInAction(() { + if (_streamInfo != null) { + _recordNativePlaybackError( + StateError(errorMessage), + StackTrace.current, + 'Native video player error', + information: [ + 'channel=$userLogin', + if (errorCode != null) 'code=$errorCode', + if (statusCode != null) 'statusCode=$statusCode', + ], + ); + _showErrorOverlay(errorMessage); + } + }); + }); + } + + /// Surfaces [message] on the video overlay and pins it (cancels auto-hide). + /// Must be called inside an action context. + void _showErrorOverlay(String message) { + _error = message; + _overlayVisible = true; + _overlayTimer?.cancel(); + } + + void _recordNativePlaybackError( + Object error, + StackTrace stackTrace, + String reason, { + List information = const [], + }) { + try { + FirebaseCrashlytics.instance.recordError( + error, + stackTrace, + reason: reason, + information: information, + ); + } catch (_) { + // Firebase may not be initialized (e.g., in tests). + } + } + + void _handleMediaServicesReset(String errorMessage) { + if (_refreshCapReached()) { + runInAction(() { + _disposeController(); + _loading = false; + _paused = true; + _isStalled = false; + _initializing = false; + _isQualitySwitching = false; + _showErrorOverlay(errorMessage); + }); + return; + } + + if (_userPaused) { + // The player is orphaned either way, but don't auto-resume a stream + // the user paused — rebuild when they tap play. + _disposeController(); + _pendingRefreshOnResume = true; + return; + } + + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: media services reset, rebuilding player ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _disposeController(); + unawaited(_handleRefresh(userInitiated: false)); + } + + bool _refreshCapReached() => + _totalRefreshAttempts >= VideoTimingConstants.maxRefreshAttempts; + + void _onPaused() { + _healthyPlaybackTimer?.cancel(); + _paused = true; + _isStalled = false; + _isQualitySwitching = false; + _stallRecoveryTimer?.cancel(); + + // If the player auto-paused during startup/buffering before playing once, + // and the user didn't explicitly pause it, force resume and trigger recovery. + if (!_userPaused && !_hasPlayedOnce && !_initializing && _error == null) { + debugPrint('NativeVideoStore: auto-paused before first play, triggering play'); + _controller?.play(); + _startStallRecoveryTimer(); + } + } + + void _startStallRecoveryTimer() { + _stallRecoveryTimer?.cancel(); + _stallRecoveryTimer = Timer( + VideoTimingConstants.stallDetectionDelay, + _runStallRecovery, + ); + } + + Future _runStallRecovery() async { + if (_disposed || + (!_loading && !_isStalled) || + _userPaused || + _initializing) { + return; + } + + // Note: recovery deliberately does NOT defer during ad breaks. Ad-pod + // transition glitches resolve well inside the 8s stall window, attempt-1 + // recovery (seek to edge + play) is harmless mid-pod, and the native + // ad-break flag can freeze at true when paused (the time observer that + // re-evaluates it stops firing) — gating recovery on it deadlocks the + // player into a permanent pause. + + // When a stream ends the HLS server stops serving segments, causing + // the player to stall indefinitely. Skip recovery if the stream is + // offline — the overlay handles that state. + await updateStreamInfo(forceUpdate: true); + if (_disposed || _streamInfo == null) return; + + _stallRecoveryAttempt++; + + if (_stallRecoveryAttempt <= 1) { + _recoverBySeekingToLiveEdge(); + } else if (_refreshCapReached()) { + _showStallError(); + } else if (_backoffDelayRemaining() > Duration.zero) { + // Still within the exponential backoff window — re-arm and try again. + debugPrint('NativeVideoStore: refresh backoff pending, re-arming timer'); + _startStallRecoveryTimer(); + } else { + _recoverByFullRefresh(); + } + } + + void _recoverBySeekingToLiveEdge() { + debugPrint('NativeVideoStore: stall detected, seeking to live edge'); + _controller?.seekToLiveEdge(); + _controller?.play(); + _startStallRecoveryTimer(); + } + + void _showStallError() { + debugPrint('NativeVideoStore: max recovery attempts reached'); + final bouncing = _shortPlayBounceCount >= + VideoTimingConstants.shortPlayLoopThreshold; + runInAction(() { + _loading = false; + _showErrorOverlay( + bouncing + ? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to the standard player in Settings.' + : 'Stream stalled. Try refreshing or switch to the standard player in Settings.', + ); + }); + } + + /// If a stall arrives within [shortPlayWindow] of the last `_onPlaying`, + /// count it as a bounce. Three bounces in a row flips the stall-error + /// message to the CDN-trouble variant. + void _recordShortPlayBounceIfNeeded() { + _healthyPlaybackTimer?.cancel(); + final start = _lastPlayingStart; + if (start == null) return; + final elapsed = DateTime.now().difference(start); + if (elapsed <= VideoTimingConstants.shortPlayWindow) { + _shortPlayBounceCount++; + } else { + _shortPlayBounceCount = 0; + } + _lastPlayingStart = null; + } + + void _scheduleHealthyPlaybackRecoveryReset(DateTime playingStartedAt) { + _healthyPlaybackTimer?.cancel(); + _healthyPlaybackTimer = Timer(VideoTimingConstants.shortPlayWindow, () { + if (_disposed || + _isStalled || + _lastPlayingStart != playingStartedAt) { + return; + } + runInAction(() { + _totalRefreshAttempts = 0; + _shortPlayBounceCount = 0; + _lastPlayingStart = null; + }); + }); + } + + /// How long to wait before the next stall-recovery full refresh, based on + /// how many have already fired. Returns zero once enough time has elapsed + /// since the last refresh, or when no refresh has happened yet. + Duration _backoffDelayRemaining() { + if (_lastRefreshTime == null) return Duration.zero; + final idx = _totalRefreshAttempts + .clamp(0, VideoTimingConstants.refreshBackoff.length - 1); + final required = VideoTimingConstants.refreshBackoff[idx]; + final elapsed = DateTime.now().difference(_lastRefreshTime!); + final remaining = required - elapsed; + return remaining > Duration.zero ? remaining : Duration.zero; + } + + void _recoverByFullRefresh() { + _totalRefreshAttempts++; + debugPrint( + 'NativeVideoStore: stall persists, refreshing player ' + '(attempt $_totalRefreshAttempts/${VideoTimingConstants.maxRefreshAttempts})', + ); + _handleRefresh(userInitiated: false); + } + + void _startStreamInfoTimer() { + _streamInfoTimer?.cancel(); + _streamInfoTimer = Timer.periodic( + VideoTimingConstants.nativeStreamInfoInterval, + (_) => _pollStreamInfoAndRecoverIfNeeded(), + ); + } + + void _stopStreamInfoTimer() { + _streamInfoTimer?.cancel(); + _streamInfoTimer = null; + } + + Future _pollStreamInfoAndRecoverIfNeeded() async { + if (_disposed) return; + final wasOffline = _streamInfo == null; + await updateStreamInfo(forceUpdate: true); + if (_disposed) return; + + if (wasOffline && _streamInfo != null && settingsStore.showVideo && !_userPaused) { + debugPrint( + 'NativeVideoStore: stream detected back online, auto-refreshing', + ); + _handleRefresh(userInitiated: false); + } + } + + void _startLatencyPolling() { + _latencyTimer?.cancel(); + _updateLatency(); + _latencyTimer = Timer.periodic( + VideoTimingConstants.latencyPollingInterval, + (_) => _updateLatency(), + ); + } + + Future _updateLatency() async { + // Program-date-time readings jump around during stitched ad breaks — + // skip the poll entirely (display, recovery, and chat sync all resume + // on the next tick after the break ends). + if (_isAdBreakActive) return; + + final seconds = await _controller?.getLatencyToLive(); + if (_disposed || seconds == null) return; + final rounded = seconds.round(); + + // Auto-recover when latency climbs too high (e.g. stream glitch, + // temporary network issue). Normal live latency is under ~15s; + // 30s+ means the player fell behind and needs intervention. + // Intentionally skips the latency display update and chat delay + // sync — both are stale at this point and will refresh after recovery. + if (rounded >= VideoTimingConstants.highLatencyThresholdSeconds && + !_paused && + !_loading && + !_userPaused) { + _highLatencyCount++; + // Still high after several interventions — the reading can't be + // trusted (device clock skew) or the device genuinely can't keep up. + // Either way, looping seeks/refreshes forever makes playback worse. + // The counter survives refreshes and only resets on a healthy + // reading, so a persistent condition stops recovery permanently. + if (_highLatencyCount > + VideoTimingConstants.maxHighLatencyRecoveries) { + return; + } + if (_isInPipMode || _highLatencyCount == 1) { + // During PiP, only seek to live edge — handleRefresh() disposes + // the controller which kills PiP. + debugPrint( + 'NativeVideoStore: high latency (${rounded}s), seeking to live edge', + ); + _controller?.seekToLiveEdge(); + _controller?.play(); + } else if (!_refreshCapReached() && + _backoffDelayRemaining() == Duration.zero) { + debugPrint( + 'NativeVideoStore: latency still high (${rounded}s), refreshing ' + '(attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + ); + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); + } + // Within the refresh cap/backoff window — wait for the next poll. + return; + } + _highLatencyCount = 0; + + final newLatency = '${rounded}s'; + if (newLatency != _latency) { + runInAction(() { + _latency = newLatency; + }); + } + + // Skip chat sync when the player isn't actively progressing against + // the live edge — a stall/pause leaves the live edge racing ahead and + // would inflate syncedChatDelay unbounded. + if (_paused || _loading || _userPaused || _isStalled) return; + _chatLatencySync.report(seconds); + } + + void _scheduleOverlayHide([ + Duration delay = VideoTimingConstants.overlayAutoHide, + ]) { + _overlayTimer?.cancel(); + + if (_isInPipMode) { + runInAction(() => _overlayVisible = true); + return; + } + + _overlayTimer = Timer(delay, () { + if (_isInPipMode) return; + runInAction(() { + _overlayVisible = false; + }); + }); + } + + @override + @action + void handleVideoTap() { + if (_isInPipMode) { + _overlayVisible = true; + return; + } + + _overlayTimer?.cancel(); + + if (_overlayVisible) { + _overlayVisible = false; + } else { + updateStreamInfo(forceUpdate: true); + _overlayVisible = true; + _scheduleOverlayHide(); + } + } + + @override + void handlePausePlay() { + if (_pendingRefreshOnResume) { + // The playback session died while user-paused (token expiry, media + // services reset) — playing the dead session would no-op or error. + _pendingRefreshOnResume = false; + _handleRefresh(userInitiated: false); + return; + } + if (_controller == null) return; + if (_paused) { + _userPaused = false; + _controller!.play(); + } else { + _userPaused = true; + _controller!.pause(); + } + } + + @override + @action + void handleToggleOverlay() { + if (settingsStore.toggleableOverlay) { + HapticFeedback.mediumImpact(); + settingsStore.showOverlay = !settingsStore.showOverlay; + + if (settingsStore.showOverlay) { + _overlayVisible = true; + _scheduleOverlayHide(VideoTimingConstants.overlayQuickHide); + } + } + } + + @override + @action + Future handleRefresh() => _handleRefresh(userInitiated: true); + + Future _handleRefresh({required bool userInitiated}) { + // Auto-recovery must never resume a stream the user paused — park the + // refresh until they tap play (handlePausePlay consumes the flag). The + // error paths set the flag themselves to preserve attempt counters; + // this guard covers every other producer (e.g. the init-retry timer). + if (!userInitiated && _userPaused) { + _pendingRefreshOnResume = true; + return Future.value(); + } + + if (userInitiated) HapticFeedback.lightImpact(); + + final wasControllerInitialized = _controllerInitialized; + runInAction(() { + // Reset recovery cap, cooldown, and bounce counter on user-initiated + // refresh (error was shown). Stall recovery calls this with + // _error == null, preserving the cap. + if (userInitiated && _error != null) { + _totalRefreshAttempts = 0; + _shortPlayBounceCount = 0; + } + _lastRefreshTime = DateTime.now(); + // PiP state is preserved here: the light path reuses the controller + // (flags must stay in sync with the native player) and the hard path + // resets it via _disposeController below. _highLatencyCount survives + // so the latency-recovery cap can stop a persistent loop; it resets + // on the next healthy reading. + _resetPlayerStateCommon( + preservePipState: true, + preserveHighLatencyCount: true, + ); + _loading = true; + _initializing = true; + _initGeneration++; + + _overlayTimer?.cancel(); + _scheduleOverlayHide(); + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _initRetryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + + if (wasControllerInitialized) return; + + // Hard refresh: controller isn't ready (init in progress or failed). + // Tear down and rebuild from scratch. + _isInPipMode = false; + _lastPipWasAutomatic = false; + _manualPipRequested = false; + _overlayWasVisibleBeforePip = true; + _disposeController(); + _controller = _createController(); + }); + + if (wasControllerInitialized) { + // Light refresh: reload stream on existing controller. PiP state is + // intentionally preserved — the controller is reused, so its observable + // PiP flags must stay in sync with the native player. + unawaited(_reloadCurrentController()); + } else { + unawaited(_initPlayer()); + } + unawaited(updateStreamInfo()); + return Future.value(); + } + + @override + void requestPictureInPicture() { + if (Platform.isAndroid) { + // autoEnter for future background transitions is managed by the showVideo + // reaction (setAutoPipMode) — don't also pass it here. + _pip.enterPipMode(); + } else { + // Skip manual PiP if the last session was auto-triggered (swipe gesture). + // Entering manual PiP right after auto-PiP causes gray window artifacts + // because the two PiP systems (AVPlayerViewController vs custom controller) + // conflict on the same player. + if (_lastPipWasAutomatic) return; + _manualPipRequested = true; + _controller?.enterPictureInPicture(); + } + } + + @override + @action + void togglePictureInPicture() { + if (_isInPipMode) { + // iOS has a programmatic exit; Android does not — the user must close + // the PiP window themselves, so the toggle is a no-op there. + if (Platform.isIOS) _controller?.exitPictureInPicture(); + return; + } + requestPictureInPicture(); + } + + @override + @action + Future updateStreamQualities() async { + // Qualities are populated automatically via qualitiesStream. + } + + @override + @action + Future setStreamQuality(String quality) async { + final index = _availableStreamQualities.indexOf(quality); + if (index == -1) return; + await _setStreamQualityIndex(index); + } + + @action + Future _setStreamQualityIndex(int index) async { + _streamQualityIndex = index; + final controller = _controller; + if (controller == null) return; + + _isQualitySwitching = true; + // A resolution-only switch never interrupts playback, so no play/pause + // event arrives to clear the flag — expire it after a grace window or + // it sticks for the session and disables stall classification. + _qualitySwitchGraceTimer?.cancel(); + _qualitySwitchGraceTimer = Timer( + VideoTimingConstants.qualitySwitchGrace, + () { + if (_disposed) return; + runInAction(() => _isQualitySwitching = false); + }, + ); + + if (index == 0) { + // 'Auto' — reset to adaptive quality + await controller.setQuality(NativeVideoPlayerQuality.auto()); + } else { + final qualityIndex = index - 1; + if (qualityIndex >= 0 && qualityIndex < _qualityObjects.length) { + await controller.setQuality(_qualityObjects[qualityIndex]); + } + } + + // The controller can be disposed or replaced during the platform call + // (user backs out, chat-only toggle, media services reset). + if (_disposed || !identical(controller, _controller)) return; + + // Toggle background PiP based on audio-only mode. + // Only call when the mode actually changes to avoid unnecessary + // method channel round-trips on every quality switch. + final quality = index == 0 + ? null + : _qualityObjects.elementAtOrNull(index - 1); + final isAudioOnly = + quality != null && quality.width == 0 && quality.height == 0; + if (isAudioOnly != _isAudioOnlyMode) { + _isAudioOnlyMode = isAudioOnly; + if (Platform.isIOS) { + if (isAudioOnly) { + controller.disableAutomaticInlinePip(); + } else { + controller.enableAutomaticInlinePip(); + } + } else if (Platform.isAndroid && _autoPipAvailable == true) { + if (isAudioOnly) { + _pip.setAutoPipMode(autoEnter: false); + } else if (settingsStore.showVideo) { + _pip.setAutoPipMode(); + } + } + } + } + + void _queueOrApplyInitialQualityIndex(int index) { + if (_hasPlayedOnce) { + unawaited(_setStreamQualityIndex(index)); + } else { + _pendingQualityIndex = index; + } + } + + @override + @action + Future updateStreamInfo({bool forceUpdate = false}) async { + final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate); + if (_disposed || result == null) return; + runInAction(() { + if (result.stream != null) { + _streamInfo = result.stream; + _offlineChannelInfo = null; + return; + } + if (result.offlineChannel == null) { + return; + } + _overlayTimer?.cancel(); + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + _loading = false; + _latency = null; + _streamInfo = null; + _offlineChannelInfo = result.offlineChannel; + // Confirmed offline (transient errors returned above) — flush the + // chat delay since there's no video to sync against. + _chatLatencySync.reset(); + }); + } + + @override + @action + void handleAppResume() { + if (_isInPipMode) return; + _highLatencyCount = 0; + updateStreamInfo(forceUpdate: true); + if (!_userPaused && !_initializing && _controller != null) { + _controller!.play(); + } + } + + @override + @action + void handleAndroidPipChanged(bool isInPip) => _setPipActive(isInPip); + + /// Shared PiP enter/exit overlay state machine, driven by the Android + /// activity callback and the iOS controller's PiP stream. + void _setPipActive(bool isInPip) { + if (isInPip && !_isInPipMode) { + _overlayWasVisibleBeforePip = _overlayVisible; + _isInPipMode = true; + _overlayTimer?.cancel(); + _overlayVisible = true; + } else if (!isInPip && _isInPipMode) { + _isInPipMode = false; + if (_overlayWasVisibleBeforePip) { + _scheduleOverlayHide(); + } else { + _overlayVisible = false; + } + } + } + + @override + @action + void dispose() { + _disposed = true; + + if (Platform.isAndroid && _autoPipAvailable == true) { + _pip.setAutoPipMode(autoEnter: false); + } + + _overlayTimer?.cancel(); + _stopStreamInfoTimer(); + _disposeAndroidAutoPipReaction?.call(); + _disposeVideoModeReaction?.call(); + _disposeController(); + } + + /// Tears down the native player and all of its observers. Releases the + /// `AVPlayer`, deactivates the audio session (when this is the last player + /// alive), and stops the PiP controller. Safe to call when no controller + /// has been created. + void _disposeController() { + _latencyTimer?.cancel(); + _stallRecoveryTimer?.cancel(); + _initRetryTimer?.cancel(); + _healthyPlaybackTimer?.cancel(); + _qualitySwitchGraceTimer?.cancel(); + _pipSub?.cancel(); + _pipSub = null; + _qualitiesSub?.cancel(); + _qualitiesSub = null; + _adBreakSub?.cancel(); + _adBreakSub = null; + _controller?.removeActivityListener(_onPlayerActivity); + _controller?.dispose(); + _controller = null; + _controllerInitialized = false; + _initInFlight = false; + _initGeneration++; + } + + /// Resets the observable fields that have the same target value across + /// "torn down for chat-only" and "fresh controller about to load". Caller + /// sets the few fields that differ (`_loading`, `_initializing`). + void _resetPlayerStateCommon({ + bool preservePipState = false, + bool preserveHighLatencyCount = false, + }) { + _hasPlayedOnce = false; + _userPaused = false; + _pendingRefreshOnResume = false; + _paused = true; + _isStalled = false; + _isAdBreakActive = false; + _restrictedStreamQualities = []; + _isQualitySwitching = false; + _isAudioOnlyMode = false; + _stallRecoveryAttempt = 0; + if (!preserveHighLatencyCount) _highLatencyCount = 0; + _firstTimeSettingQuality = true; + _pendingQualityIndex = null; + _streamQualityIndex = 0; + _availableStreamQualities = []; + _qualityObjects = []; + _latency = null; + _error = null; + if (!preservePipState) { + _isInPipMode = false; + _lastPipWasAutomatic = false; + _manualPipRequested = false; + _overlayWasVisibleBeforePip = true; + } + } + + /// Resets observable display state after [_disposeController] so the UI + /// reflects "no active player". Called only when entering chat-only mode; + /// final disposal skips this since the store is being torn down. + @action + void _resetPlayerStateForChatOnly() { + _resetPlayerStateCommon(); + _loading = false; + _initializing = false; + _chatLatencySync.reset(); + } + + /// Builds a brand new controller and kicks off `_initPlayer`. Used when + /// the user toggles back to video mode after a chat-only stretch. + @action + void _initFreshController() { + _resetPlayerStateCommon(); + _loading = true; + _initializing = true; + _controller = _createController(); + _initPlayer(); + } +} diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart new file mode 100644 index 00000000..8f1167ce --- /dev/null +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -0,0 +1,497 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'native_video_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$NativeVideoStore on NativeVideoStoreBase, Store { + Computed? _$streamQualityComputed; + + @override + String get streamQuality => (_$streamQualityComputed ??= Computed( + () => super.streamQuality, + name: 'NativeVideoStoreBase.streamQuality', + )).value; + + late final _$_controllerAtom = Atom( + name: 'NativeVideoStoreBase._controller', + context: context, + ); + + NativeVideoPlayerController? get controller { + _$_controllerAtom.reportRead(); + return super._controller; + } + + @override + NativeVideoPlayerController? get _controller => controller; + + @override + set _controller(NativeVideoPlayerController? value) { + _$_controllerAtom.reportWrite(value, super._controller, () { + super._controller = value; + }); + } + + late final _$_loadingAtom = Atom( + name: 'NativeVideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + + late final _$_pausedAtom = Atom( + name: 'NativeVideoStoreBase._paused', + context: context, + ); + + bool get paused { + _$_pausedAtom.reportRead(); + return super._paused; + } + + @override + bool get _paused => paused; + + @override + set _paused(bool value) { + _$_pausedAtom.reportWrite(value, super._paused, () { + super._paused = value; + }); + } + + late final _$_hasPlayedOnceAtom = Atom( + name: 'NativeVideoStoreBase._hasPlayedOnce', + context: context, + ); + + bool get hasPlayedOnce { + _$_hasPlayedOnceAtom.reportRead(); + return super._hasPlayedOnce; + } + + @override + bool get _hasPlayedOnce => hasPlayedOnce; + + @override + set _hasPlayedOnce(bool value) { + _$_hasPlayedOnceAtom.reportWrite(value, super._hasPlayedOnce, () { + super._hasPlayedOnce = value; + }); + } + + late final _$_overlayVisibleAtom = Atom( + name: 'NativeVideoStoreBase._overlayVisible', + context: context, + ); + + bool get overlayVisible { + _$_overlayVisibleAtom.reportRead(); + return super._overlayVisible; + } + + @override + bool get _overlayVisible => overlayVisible; + + @override + set _overlayVisible(bool value) { + _$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () { + super._overlayVisible = value; + }); + } + + late final _$_streamInfoAtom = Atom( + name: 'NativeVideoStoreBase._streamInfo', + context: context, + ); + + StreamTwitch? get streamInfo { + _$_streamInfoAtom.reportRead(); + return super._streamInfo; + } + + @override + StreamTwitch? get _streamInfo => streamInfo; + + @override + set _streamInfo(StreamTwitch? value) { + _$_streamInfoAtom.reportWrite(value, super._streamInfo, () { + super._streamInfo = value; + }); + } + + late final _$_offlineChannelInfoAtom = Atom( + name: 'NativeVideoStoreBase._offlineChannelInfo', + context: context, + ); + + Channel? get offlineChannelInfo { + _$_offlineChannelInfoAtom.reportRead(); + return super._offlineChannelInfo; + } + + @override + Channel? get _offlineChannelInfo => offlineChannelInfo; + + @override + set _offlineChannelInfo(Channel? value) { + _$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () { + super._offlineChannelInfo = value; + }); + } + + late final _$_availableStreamQualitiesAtom = Atom( + name: 'NativeVideoStoreBase._availableStreamQualities', + context: context, + ); + + List get availableStreamQualities { + _$_availableStreamQualitiesAtom.reportRead(); + return super._availableStreamQualities; + } + + @override + List get _availableStreamQualities => availableStreamQualities; + + @override + set _availableStreamQualities(List value) { + _$_availableStreamQualitiesAtom.reportWrite( + value, + super._availableStreamQualities, + () { + super._availableStreamQualities = value; + }, + ); + } + + late final _$_restrictedStreamQualitiesAtom = Atom( + name: 'NativeVideoStoreBase._restrictedStreamQualities', + context: context, + ); + + List get restrictedStreamQualities { + _$_restrictedStreamQualitiesAtom.reportRead(); + return super._restrictedStreamQualities; + } + + @override + List get _restrictedStreamQualities => restrictedStreamQualities; + + @override + set _restrictedStreamQualities(List value) { + _$_restrictedStreamQualitiesAtom.reportWrite( + value, + super._restrictedStreamQualities, + () { + super._restrictedStreamQualities = value; + }, + ); + } + + late final _$_streamQualityIndexAtom = Atom( + name: 'NativeVideoStoreBase._streamQualityIndex', + context: context, + ); + + int get streamQualityIndex { + _$_streamQualityIndexAtom.reportRead(); + return super._streamQualityIndex; + } + + @override + int get _streamQualityIndex => streamQualityIndex; + + @override + set _streamQualityIndex(int value) { + _$_streamQualityIndexAtom.reportWrite(value, super._streamQualityIndex, () { + super._streamQualityIndex = value; + }); + } + + late final _$_latencyAtom = Atom( + name: 'NativeVideoStoreBase._latency', + context: context, + ); + + String? get latency { + _$_latencyAtom.reportRead(); + return super._latency; + } + + @override + String? get _latency => latency; + + @override + set _latency(String? value) { + _$_latencyAtom.reportWrite(value, super._latency, () { + super._latency = value; + }); + } + + late final _$_isInPipModeAtom = Atom( + name: 'NativeVideoStoreBase._isInPipMode', + context: context, + ); + + bool get isInPipMode { + _$_isInPipModeAtom.reportRead(); + return super._isInPipMode; + } + + @override + bool get _isInPipMode => isInPipMode; + + @override + set _isInPipMode(bool value) { + _$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () { + super._isInPipMode = value; + }); + } + + late final _$_isAudioOnlyModeAtom = Atom( + name: 'NativeVideoStoreBase._isAudioOnlyMode', + context: context, + ); + + bool get isAudioOnlyMode { + _$_isAudioOnlyModeAtom.reportRead(); + return super._isAudioOnlyMode; + } + + @override + bool get _isAudioOnlyMode => isAudioOnlyMode; + + @override + set _isAudioOnlyMode(bool value) { + _$_isAudioOnlyModeAtom.reportWrite(value, super._isAudioOnlyMode, () { + super._isAudioOnlyMode = value; + }); + } + + late final _$_errorAtom = Atom( + name: 'NativeVideoStoreBase._error', + context: context, + ); + + String? get error { + _$_errorAtom.reportRead(); + return super._error; + } + + @override + String? get _error => error; + + @override + set _error(String? value) { + _$_errorAtom.reportWrite(value, super._error, () { + super._error = value; + }); + } + + late final _$_initPlayerAsyncAction = AsyncAction( + 'NativeVideoStoreBase._initPlayer', + context: context, + ); + + @override + Future _initPlayer() { + return _$_initPlayerAsyncAction.run(() => super._initPlayer()); + } + + late final _$updateStreamQualitiesAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamQualities', + context: context, + ); + + @override + Future updateStreamQualities() { + return _$updateStreamQualitiesAsyncAction.run( + () => super.updateStreamQualities(), + ); + } + + late final _$setStreamQualityAsyncAction = AsyncAction( + 'NativeVideoStoreBase.setStreamQuality', + context: context, + ); + + @override + Future setStreamQuality(String quality) { + return _$setStreamQualityAsyncAction.run( + () => super.setStreamQuality(quality), + ); + } + + late final _$_setStreamQualityIndexAsyncAction = AsyncAction( + 'NativeVideoStoreBase._setStreamQualityIndex', + context: context, + ); + + @override + Future _setStreamQualityIndex(int index) { + return _$_setStreamQualityIndexAsyncAction.run( + () => super._setStreamQualityIndex(index), + ); + } + + late final _$updateStreamInfoAsyncAction = AsyncAction( + 'NativeVideoStoreBase.updateStreamInfo', + context: context, + ); + + @override + Future updateStreamInfo({bool forceUpdate = false}) { + return _$updateStreamInfoAsyncAction.run( + () => super.updateStreamInfo(forceUpdate: forceUpdate), + ); + } + + late final _$NativeVideoStoreBaseActionController = ActionController( + name: 'NativeVideoStoreBase', + context: context, + ); + + @override + Future _reloadCurrentController() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._reloadCurrentController', + ); + try { + return super._reloadCurrentController(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleVideoTap() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleVideoTap', + ); + try { + return super.handleVideoTap(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleToggleOverlay() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleToggleOverlay', + ); + try { + return super.handleToggleOverlay(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + Future handleRefresh() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleRefresh', + ); + try { + return super.handleRefresh(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void togglePictureInPicture() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.togglePictureInPicture', + ); + try { + return super.togglePictureInPicture(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleAppResume() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleAppResume', + ); + try { + return super.handleAppResume(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void handleAndroidPipChanged(bool isInPip) { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.handleAndroidPipChanged', + ); + try { + return super.handleAndroidPipChanged(isInPip); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void dispose() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase.dispose', + ); + try { + return super.dispose(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _resetPlayerStateForChatOnly() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._resetPlayerStateForChatOnly', + ); + try { + return super._resetPlayerStateForChatOnly(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _initFreshController() { + final _$actionInfo = _$NativeVideoStoreBaseActionController.startAction( + name: 'NativeVideoStoreBase._initFreshController', + ); + try { + return super._initFreshController(); + } finally { + _$NativeVideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +streamQuality: ${streamQuality} + '''; + } +} diff --git a/lib/screens/channel/video/stream_info_poller.dart b/lib/screens/channel/video/stream_info_poller.dart new file mode 100644 index 00000000..5917ffed --- /dev/null +++ b/lib/screens/channel/video/stream_info_poller.dart @@ -0,0 +1,86 @@ +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Snapshot of a channel's live state. +class StreamInfoResult { + const StreamInfoResult({this.stream, this.offlineChannel}); + + /// Non-null when the channel is currently live. + final StreamTwitch? stream; + + /// Non-null when the channel is confirmed offline (channel metadata fetched + /// successfully but no live stream). Both fields null means the fetch + /// failed entirely (network error, etc.). + final Channel? offlineChannel; +} + +/// Debounced, deduplicated Twitch stream-info fetcher shared by both video +/// stores. Each store still owns its observable `_streamInfo` / +/// `_offlineChannelInfo` fields and decides how to react to the result. +class StreamInfoPoller { + StreamInfoPoller({ + required this.twitchApi, + required this.userLogin, + required this.userId, + }); + + final TwitchApi twitchApi; + final String userLogin; + final String userId; + + DateTime? _lastUpdate; + Future? _inFlight; + + /// Fetches the latest stream info, returning `null` if the call was + /// debounced (caller should skip updating state). Concurrent calls await + /// the in-flight request instead of issuing a new one. + Future fetch({bool forceUpdate = false}) async { + final pending = _inFlight; + if (pending != null) return pending; + + final now = DateTime.now(); + if (!forceUpdate && _lastUpdate != null) { + if (now.difference(_lastUpdate!) < + VideoTimingConstants.streamInfoDebounce) { + return null; + } + } + + _lastUpdate = now; + final request = _runFetch(); + _inFlight = request; + try { + return await request; + } finally { + if (identical(_inFlight, request)) _inFlight = null; + } + } + + Future _runFetch() async { + try { + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + // Helix can transiently drop a live stream from /streams (or 500 on + // one endpoint while others succeed). A false "confirmed offline" + // tears down latency polling and chat delay on healthy playback, so + // require a second failed check before falling back to /channels. + try { + await Future.delayed( + VideoTimingConstants.offlineConfirmationDelay, + ); + final info = await twitchApi.getStream(userLogin: userLogin); + return StreamInfoResult(stream: info); + } catch (_) { + try { + final channel = await twitchApi.getChannel(userId: userId); + return StreamInfoResult(offlineChannel: channel); + } catch (_) { + return const StreamInfoResult(); + } + } + } + } +} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 62fb13b3..eceb6c08 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; -import 'package:frosty/screens/channel/video/video_store.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; @@ -21,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Creates a widget containing controls which enable interactions with an underlying [Video] widget. class VideoOverlay extends StatelessWidget { - final VideoStore videoStore; + final VideoPlayerInterface videoStore; final ChatStore chatStore; final SettingsStore settingsStore; @@ -88,7 +90,7 @@ class VideoOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionHeader( - 'Stream quality', + 'Quality', padding: EdgeInsets.fromLTRB(16, 0, 16, 8), isFirst: true, ), @@ -97,26 +99,35 @@ class VideoOverlay extends StatelessWidget { builder: (context) => ListView( shrinkWrap: true, primary: false, - children: videoStore.availableStreamQualities - .map( - (quality) => ListTile( - trailing: videoStore.streamQuality == quality - ? const Icon(Icons.check_rounded) - : null, - title: Text(quality), - onTap: () { - videoStore.setStreamQuality(quality); - SharedPreferences.getInstance().then( - (prefs) => prefs.setString( - 'last_stream_quality', - quality, - ), - ); - Navigator.pop(context); - }, - ), - ) - .toList(), + children: [ + ...videoStore.availableStreamQualities.map( + (quality) => ListTile( + trailing: videoStore.streamQuality == quality + ? const Icon(Icons.check_rounded) + : null, + title: Text(quality), + onTap: () { + videoStore.setStreamQuality(quality); + SharedPreferences.getInstance().then( + (prefs) => prefs.setString( + lastStreamQualityKey(videoStore.userLogin), + quality, + ), + ); + Navigator.pop(context); + }, + ), + ), + // Qualities the broadcaster offers only to subscribers + // (from the playback token's restricted_bitrates). + ...videoStore.restrictedStreamQualities.map( + (quality) => ListTile( + enabled: false, + title: Text(quality), + subtitle: const Text('Subscribers only'), + ), + ), + ], ), ), ), @@ -259,7 +270,6 @@ class VideoOverlay extends StatelessWidget { displayName: chatStore.displayName, showUptime: false, showViewerCount: false, - showOfflineIndicator: false, textColor: surfaceColor, isOffline: true, isInSharedChatMode: @@ -344,32 +354,36 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && context.isLandscape) chatOverlayButton, - if (!Platform.isIOS || isIPad()) videoSettingsButton, + if (!Platform.isIOS || + isIPad() || + videoStore is NativeVideoStore) + videoSettingsButton, ], ), - Center( - child: Tooltip( - message: videoStore.paused ? 'Play' : 'Pause', - preferBelow: false, - child: IconButton( - iconSize: 56, - icon: Icon( - videoStore.paused - ? Icons.play_arrow_rounded - : Icons.pause_rounded, - color: surfaceColor, - shadows: [ - Shadow( - offset: const Offset(0, 3), - blurRadius: 8, - color: Colors.black.withValues(alpha: 0.6), - ), - ], + if (!videoStore.loading) + Center( + child: Tooltip( + message: videoStore.paused ? 'Play' : 'Pause', + preferBelow: false, + child: IconButton( + iconSize: 56, + icon: Icon( + videoStore.paused + ? Icons.play_arrow_rounded + : Icons.pause_rounded, + color: surfaceColor, + shadows: [ + Shadow( + offset: const Offset(0, 3), + blurRadius: 8, + color: Colors.black.withValues(alpha: 0.6), + ), + ], + ), + onPressed: videoStore.handlePausePlay, ), - onPressed: videoStore.handlePausePlay, ), ), - ), Align( alignment: Alignment.bottomLeft, child: Row( diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart new file mode 100644 index 00000000..b2507f1f --- /dev/null +++ b/lib/screens/channel/video/video_player_interface.dart @@ -0,0 +1,43 @@ +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +/// Abstraction over the video player store so both [VideoStore] (WebView) +/// and [NativeVideoStore] can drive the same overlay and channel UI. +/// +/// Native-only capabilities live on [NativeVideoPlayerInterface]. +abstract class VideoPlayerInterface { + SettingsStore get settingsStore; + + /// Channel this store is driving — used as a key for per-channel + /// persistence (e.g. last-selected quality). + String get userLogin; + + bool get loading; + bool get paused; + bool get overlayVisible; + bool get isInPipMode; + StreamTwitch? get streamInfo; + Channel? get offlineChannelInfo; + List get availableStreamQualities; + + /// Qualities that exist for this stream but require a subscription. + /// Only the native player can know this (parsed from the playback token); + /// the WebView player always reports none. + List get restrictedStreamQualities; + + String get streamQuality; + String? get latency; + + void handleVideoTap(); + void handlePausePlay(); + void handleToggleOverlay(); + Future handleRefresh(); + void requestPictureInPicture(); + void togglePictureInPicture(); + Future updateStreamQualities(); + Future setStreamQuality(String quality); + Future updateStreamInfo({bool forceUpdate}); + void handleAppResume(); + void dispose(); +} diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 07889964..d75a2718 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -5,8 +5,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; +import 'package:frosty/screens/channel/video/stream_info_poller.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:mobx/mobx.dart'; @@ -20,10 +25,11 @@ part 'video_store.g.dart'; class VideoStore = VideoStoreBase with _$VideoStore; -abstract class VideoStoreBase with Store { +abstract class VideoStoreBase with Store implements VideoPlayerInterface { final TwitchApi twitchApi; /// The userlogin of the current channel. + @override final String userLogin; /// The user ID of the current channel. @@ -31,12 +37,21 @@ abstract class VideoStoreBase with Store { final AuthStore authStore; + @override final SettingsStore settingsStore; /// The [SimplePip] instance used for initiating PiP on Android. final pip = SimplePip(); + late final ChatLatencySync _chatLatencySync = + ChatLatencySync(settingsStore: settingsStore); + late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller( + twitchApi: twitchApi, + userLogin: userLogin, + userId: userId, + ); var _firstTimeSettingQuality = true; + var _highLatencyCount = 0; /// Whether [initVideo] should run on the next [onPageFinished]. /// @@ -58,8 +73,6 @@ abstract class VideoStoreBase with Store { final receivedLatency = message.message; _latency = receivedLatency; - if (!settingsStore.autoSyncChatDelay) return; - // Parse latency from abbreviated format: "5s" -> 5.0 final numericPart = receivedLatency.replaceAll( RegExp(r'[^0-9.]'), @@ -67,8 +80,43 @@ abstract class VideoStoreBase with Store { ); final latencyAsDouble = double.tryParse(numericPart); + // Auto-recover when latency climbs too high. + // Intentionally skips the chat delay sync — the stale value + // would desync chat further. Recovery will restore it. + if (latencyAsDouble != null && + latencyAsDouble >= VideoTimingConstants.highLatencyThresholdSeconds && + !_paused && + !_loading) { + _highLatencyCount++; + if (_highLatencyCount == 1) { + debugPrint( + 'VideoStore: high latency (${latencyAsDouble.round()}s), seeking to live edge', + ); + try { + videoWebViewController.runJavaScript(''' + (function() { + const v = window._videoEl || document.querySelector("video"); + if (v && v.buffered.length > 0) { + v.currentTime = v.buffered.end(v.buffered.length - 1); + } + })(); + '''); + } catch (e) { + debugPrint(e.toString()); + } + } else { + debugPrint( + 'VideoStore: latency still high (${latencyAsDouble.round()}s), refreshing', + ); + _highLatencyCount = 0; + handleRefresh(); + } + return; + } + _highLatencyCount = 0; + if (latencyAsDouble != null) { - settingsStore.syncedChatDelay = latencyAsDouble; + _chatLatencySync.report(latencyAsDouble); } }, ) @@ -86,7 +134,8 @@ abstract class VideoStoreBase with Store { return; } final prefs = await SharedPreferences.getInstance(); - final lastStreamQuality = prefs.getString('last_stream_quality'); + final lastStreamQuality = + prefs.getString(lastStreamQualityKey(userLogin)); if (lastStreamQuality == null) return; setStreamQuality(lastStreamQuality); } @@ -102,6 +151,7 @@ abstract class VideoStoreBase with Store { ..addJavaScriptChannel( 'VideoPlaying', onMessageReceived: (message) { + _loading = false; _paused = false; if (Platform.isAndroid) pip.setIsPlaying(true); }, @@ -152,12 +202,6 @@ abstract class VideoStoreBase with Store { /// Timer for periodic JavaScript state cleanup to prevent memory accumulation. Timer? _jsCleanupTimer; - /// Tracks the last time stream info was updated to prevent double refresh - DateTime? _lastStreamInfoUpdate; - - /// Tracks an in-flight stream info request so refreshes can be deduplicated. - Future? _streamInfoRequest; - /// Disposes the overlay reactions. late final ReactionDisposer _disposeOverlayReaction; @@ -169,6 +213,9 @@ abstract class VideoStoreBase with Store { /// Disposes the latency settings reaction. ReactionDisposer? _disposeLatencySettingsReaction; + @readonly + var _loading = true; + /// If the video is currently paused. /// /// Does not pause or play the video, only used for rendering state of the overlay. @@ -190,11 +237,17 @@ abstract class VideoStoreBase with Store { @readonly List _availableStreamQualities = []; + // The WebView player has no access to the playback token, so sub-only + // qualities can't be detected here. + @override + List get restrictedStreamQualities => const []; + // The current stream quality index @readonly int _streamQualityIndex = 0; // The current stream quality string + @override String get streamQuality => _availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto'; @@ -217,10 +270,9 @@ abstract class VideoStoreBase with Store { required this.authStore, required this.settingsStore, }) { - // Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values - if (settingsStore.autoSyncChatDelay) { - settingsStore.syncedChatDelay = 0.0; - } + // Reset chat delay to 0 to prevent starting with a stale value from + // a previous channel. + _chatLatencySync.reset(); // Initialize the video webview params for iOS to enable video autoplay. if (WebViewPlatform.instance is WebKitWebViewPlatform) { _videoWebViewParams = WebKitWebViewControllerCreationParams( @@ -253,13 +305,7 @@ abstract class VideoStoreBase with Store { await updateStreamQualities(); } else { _showDefaultOverlay(); - try { - videoWebViewController.runJavaScript( - 'window._latencyTracker?.stop()', - ); - } catch (e) { - debugPrint(e.toString()); - } + _stopLatencyTracker(); } }); @@ -277,6 +323,9 @@ abstract class VideoStoreBase with Store { _stopJsCleanupTimer(); // Ensure overlay timer is active for clean UI _scheduleOverlayHide(); + // The tracker's 60s cycle opens the Twitch settings menu via DOM + // queries — keep that off when the player isn't even visible. + _stopLatencyTracker(); } }); @@ -308,8 +357,9 @@ abstract class VideoStoreBase with Store { (_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay), (values) async { final (showLatency, autoSync) = values; - // Only act if overlay is enabled (latency tracker only works with custom overlay) - if (!settingsStore.showOverlay) return; + // Tracker only runs against the live player iframe — skip in + // chat-only mode and when the custom overlay is off. + if (!settingsStore.showVideo || !settingsStore.showOverlay) return; if (showLatency || autoSync) { // Start tracker if either setting is now enabled @@ -326,23 +376,18 @@ abstract class VideoStoreBase with Store { ); final latencyAsDouble = double.tryParse(numericPart); if (latencyAsDouble != null) { - settingsStore.syncedChatDelay = latencyAsDouble; + _chatLatencySync.report(latencyAsDouble); } } } else { // Stop tracker if both settings are now disabled - try { - videoWebViewController.runJavaScript( - 'window._latencyTracker?.stop()', - ); - } catch (e) { - debugPrint(e.toString()); - } + _stopLatencyTracker(); } }, ); } + @override @action Future updateStreamQualities() async { try { @@ -382,6 +427,7 @@ abstract class VideoStoreBase with Store { } } + @override @action Future setStreamQuality(String newStreamQuality) async { final indexOfStreamQuality = _availableStreamQualities.indexOf( @@ -492,160 +538,23 @@ abstract class VideoStoreBase with Store { /// - Skips cycles entirely when overlay hidden (saves CPU) Future _listenOnLatencyChanges() async { try { - await videoWebViewController.runJavaScript(r''' - window._latencyTracker ??= { - CYCLE_INTERVAL: 60000, - STATS_ACTIVE_TIME: 1500, - INITIAL_RETRY_INTERVAL: 3000, - MAX_INITIAL_RETRIES: 4, - - cycleCount: 0, - hasInitialLatency: false, - timeoutId: null, - isRunning: false, - overlayVisible: true, - isCycling: false, - - init() { - if (this.isRunning) return; - this.isRunning = true; - if (!this.isCycling && !this.timeoutId) { - this._cycle(); - } - }, - - stop() { - this.isRunning = false; - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } - }, - - setOverlayVisible(visible) { - this.overlayVisible = visible; - // Resume immediately when overlay becomes visible - if (visible && this.isRunning && !this.isCycling && !this.timeoutId) { - this._cycle(); - } - }, - - async _cycle() { - if (this.isCycling) return; - this.isCycling = true; - - try { - // Clear timeout ID synchronously at entry to prevent race conditions - // (e.g., setOverlayVisible calling _cycle while timeout is firing) - const currentTimeoutId = this.timeoutId; - this.timeoutId = null; - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); - } - - if (!this.isRunning) return; - - // Skip cycle entirely if overlay not visible - major CPU savings - if (!this.overlayVisible) { - this.timeoutId = setTimeout(() => this._cycle(), 5000); - return; - } - - // Defer if another operation (e.g., quality selection) is using the settings menu - if (window._promiseQueueLength > 0) { - this.timeoutId = setTimeout(() => this._cycle(), 2000); - return; - } - - this.cycleCount++; - const cycleStart = Date.now(); - - await this._setStatsEnabled(true); - - // Wait longer on first cycle for stats to populate - const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; - await new Promise(resolve => setTimeout(resolve, waitTime)); - - this._readLatency(); - await this._setStatsEnabled(false); - - if (!this.isRunning) return; - - const totalActiveTime = Date.now() - cycleStart; - - // Calculate next interval - let nextInterval; - if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { - nextInterval = this.INITIAL_RETRY_INTERVAL; - } else { - nextInterval = this.CYCLE_INTERVAL - totalActiveTime; - } - - // Schedule next cycle - this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); - } finally { - this.isCycling = false; - } - }, - - async _setStatsEnabled(enable) { - try { - await _queuePromise(async () => { - const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); - if (!settingsBtn) return; - settingsBtn.click(); - - const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]'); - if (!advancedItem) { - settingsBtn.click(); - return; - } - advancedItem.click(); - - const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); - if (statsCheckbox && statsCheckbox.checked !== enable) { - statsCheckbox.click(); - } - - settingsBtn.click(); - }); - } catch (error) { - // Silently fail - stats panel may not be available - } - }, - - _readLatency() { - try { - const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]'); - if (latencyElement && latencyElement.textContent) { - let latencyText = latencyElement.textContent.trim(); - - // Convert to whole number with abbreviated unit: "4.69 sec." -> "5s" - const match = latencyText.match(/([0-9.]+)\s*sec/i); - if (match) { - const rounded = Math.round(parseFloat(match[1])); - latencyText = rounded + 's'; - this.hasInitialLatency = true; - } - - if (window.Latency) { - Latency.postMessage(latencyText); - } - } - } catch (error) { - // Silently fail - } - } - }; - - window._latencyTracker.init(); - '''); + await videoWebViewController.runJavaScript(_kLatencyTrackerJs); _updateLatencyTrackerVisibility(_overlayVisible); } catch (e) { debugPrint(e.toString()); } } + /// Tells the in-webview latency tracker to stop its polling cycle. + /// Safe to call when the tracker hasn't been injected yet. + void _stopLatencyTracker() { + try { + videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); + } catch (e) { + debugPrint(e.toString()); + } + } + /// Updates the latency tracker's overlay visibility state. /// /// When overlay is hidden, the latency tracker pauses its polling cycle @@ -708,140 +617,24 @@ abstract class VideoStoreBase with Store { /// Initializes the video webview. @action Future initVideo() async { - if (await videoWebViewController.currentUrl() == videoUrl) { - // Declare `window` level utility methods and add event listeners to notify the JavaScript channels when the video plays and pauses. - try { - await videoWebViewController.runJavaScript(''' - // Promise queue with length limit and generation tracking - // Generation tracking prevents counter from going negative when queue resets - window._PROMISE_QUEUE = Promise.resolve(); - window._promiseQueueLength = 0; - window._promiseQueueGen = 0; - - window._queuePromise = (method) => { - window._promiseQueueLength++; - - // Reset queue if it gets too long (prevents memory accumulation) - if (window._promiseQueueLength > 30) { - window._PROMISE_QUEUE = Promise.resolve(); - window._promiseQueueLength = 1; - window._promiseQueueGen++; - } - - const myGen = window._promiseQueueGen; - - window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(async () => { - try { - await method(); - } catch (e) { - console.warn('Queue promise error:', e); - } finally { - // Only decrement if still same generation (not reset) - if (myGen === window._promiseQueueGen) { - window._promiseQueueLength--; - } - } - }); - - return window._PROMISE_QUEUE; - }; - window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => { - let element = document.querySelector(selector); - if (element) { - return resolve(element); - } - - let timeoutId; - const observer = new MutationObserver(() => { - element = document.querySelector(selector); - if (element) { - observer.disconnect(); - clearTimeout(timeoutId); - resolve(element); - } - }); - // Must use subtree to detect nested elements in the player - observer.observe(document.body, { childList: true, subtree: true }); - - // Always set timeout with default of 10 seconds - timeoutId = setTimeout(() => { - observer.disconnect(); - resolve(undefined); - }, timeout); - }); - - _queuePromise(async () => { - const videoElement = await _asyncQuerySelector("video"); - - // Null check in case element was not found within timeout - if (!videoElement) { - console.warn("Video element not found within timeout"); - return; - } - - // Cache for reuse in play/pause/PiP calls - window._videoEl = videoElement; - - // Prevent duplicate event listener registration - if (!videoElement._listenersAdded) { - videoElement._listenersAdded = true; - - videoElement.addEventListener("pause", () => { - VideoPause.postMessage("video paused"); - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - }); - videoElement.addEventListener("playing", () => { - VideoPlaying.postMessage("video playing"); - // Ensure video is unmuted and captions are hidden - videoElement.muted = false; - videoElement.volume = 1.0; - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - }); - - // Add PiP event listeners for iOS - videoElement.addEventListener("enterpictureinpicture", () => { - PipEntered.postMessage("pip entered"); - }); - videoElement.addEventListener("leavepictureinpicture", () => { - PipExited.postMessage("pip exited"); - }); - } - - if (!videoElement.paused) { - VideoPlaying.postMessage("video playing"); - // Ensure video is unmuted and captions are hidden - videoElement.muted = false; - videoElement.volume = 1.0; - if (videoElement.textTracks && videoElement.textTracks.length > 0) { - videoElement.textTracks[0].mode = "hidden"; - } - } - - // Explicitly attempt autoplay in case the player loaded paused - try { await videoElement.play(); } catch (e) {} - }); - '''); - if (settingsStore.showOverlay) { - await _hideDefaultOverlay(); - // Start latency tracking if either: - // - showLatency is enabled (user wants to see it on overlay), OR - // - autoSyncChatDelay is enabled (needs latency data for syncing) - if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { - await _listenOnLatencyChanges(); - } - await updateStreamQualities(); - } - } catch (e) { - debugPrint(e.toString()); + if (await videoWebViewController.currentUrl() != videoUrl) return; + try { + await videoWebViewController.runJavaScript(_kInitVideoJs); + if (!settingsStore.showOverlay) return; + await _hideDefaultOverlay(); + // Start latency tracking if either showLatency (user-visible) or + // autoSyncChatDelay (needs latency to sync chat) is enabled. + if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { + await _listenOnLatencyChanges(); } + await updateStreamQualities(); + } catch (e) { + debugPrint(e.toString()); } } /// Called whenever the video/overlay is tapped. + @override @action void handleVideoTap() { if (_isInPipMode) { @@ -870,7 +663,7 @@ abstract class VideoStoreBase with Store { // Only start if not already active if (_streamInfoTimer?.isActive != true) { _streamInfoTimer = Timer.periodic( - const Duration(seconds: 60), + VideoTimingConstants.webviewStreamInfoInterval, (_) => updateStreamInfo(), ); } @@ -888,7 +681,7 @@ abstract class VideoStoreBase with Store { if (_jsCleanupTimer?.isActive == true) return; _jsCleanupTimer = Timer.periodic( - const Duration(minutes: 10), + VideoTimingConstants.jsCleanupInterval, (_) => _performJsSoftReset(), ); } @@ -898,7 +691,9 @@ abstract class VideoStoreBase with Store { _jsCleanupTimer = null; } - void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) { + void _scheduleOverlayHide([ + Duration delay = VideoTimingConstants.overlayAutoHide, + ]) { _overlayTimer?.cancel(); if (_isInPipMode) { @@ -918,6 +713,7 @@ abstract class VideoStoreBase with Store { } /// Handles app resume event for immediate stream info refresh in chat-only mode. + @override @action void handleAppResume() { // Only refresh immediately in chat-only mode @@ -930,63 +726,33 @@ abstract class VideoStoreBase with Store { /// /// If the stream is offline, fetches channel information to show offline details. /// Set [forceUpdate] to true to bypass the rate limiting check. + @override @action Future updateStreamInfo({bool forceUpdate = false}) async { - if (_streamInfoRequest != null) { - await _streamInfoRequest; - return; - } - - // Rate limiting: prevent too frequent updates unless forced - final now = DateTime.now(); - if (!forceUpdate && _lastStreamInfoUpdate != null) { - final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!); - if (timeSinceLastUpdate.inSeconds < 5) { - return; // Skip update if less than 5 seconds since last update + final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate); + if (result == null) return; + runInAction(() { + if (result.stream != null) { + _streamInfo = result.stream; + _offlineChannelInfo = null; + return; } - } - - _lastStreamInfoUpdate = now; - - final request = _updateStreamInfoInternal(); - _streamInfoRequest = request; - - try { - await request; - } finally { - if (identical(_streamInfoRequest, request)) { - _streamInfoRequest = null; - } - } - } - - Future _updateStreamInfoInternal() async { - try { - _streamInfo = await twitchApi.getStream(userLogin: userLogin); - // Clear offline info when stream is live - _offlineChannelInfo = null; - } catch (e) { _overlayTimer?.cancel(); + _loading = false; _streamInfo = null; _paused = true; - - // Try to fetch offline channel information - try { - _offlineChannelInfo = await twitchApi.getChannel(userId: userId); - } catch (channelError) { - _offlineChannelInfo = null; - } - + _offlineChannelInfo = result.offlineChannel; // Restart overlay timer in chat-only mode even on error if (!settingsStore.showVideo) { _scheduleOverlayHide(); } - } + }); } /// Handles the toggle overlay options. /// /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. + @override @action void handleToggleOverlay() { if (settingsStore.toggleableOverlay) { @@ -996,7 +762,7 @@ abstract class VideoStoreBase with Store { if (settingsStore.showOverlay) { _overlayVisible = true; - _scheduleOverlayHide(const Duration(seconds: 3)); + _scheduleOverlayHide(VideoTimingConstants.overlayQuickHide); } } @@ -1004,23 +770,25 @@ abstract class VideoStoreBase with Store { } /// Refreshes the stream webview and updates the stream info. + @override @action Future handleRefresh() async { HapticFeedback.lightImpact(); + _loading = true; _paused = true; _firstTimeSettingQuality = true; + _highLatencyCount = 0; _isInPipMode = false; _latency = null; _availableStreamQualities = []; + _overlayTimer?.cancel(); + _scheduleOverlayHide(); + // Signal that initVideo() should run on the next onPageFinished _needsInit = true; - try { - videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); - } catch (e) { - // Ignore - may not exist yet - } + _stopLatencyTracker(); await videoWebViewController.loadRequest(Uri.parse(videoUrl)); @@ -1028,6 +796,7 @@ abstract class VideoStoreBase with Store { } /// Play or pause the video depending on the current state of [_paused]. + @override void handlePausePlay() { try { if (_paused) { @@ -1048,6 +817,7 @@ abstract class VideoStoreBase with Store { /// /// On Android, this will utilize the native Android PiP API. /// On iOS, this will utilize the web picture-in-picture API. + @override void requestPictureInPicture() { try { if (Platform.isAndroid) { @@ -1067,6 +837,7 @@ abstract class VideoStoreBase with Store { /// If not in PiP mode, enters PiP mode. /// If already in PiP mode on iOS, exits PiP mode. /// On Android, always enters PiP mode (no programmatic exit or state tracking). + @override @action void togglePictureInPicture() { try { @@ -1088,6 +859,7 @@ abstract class VideoStoreBase with Store { } } + @override @action void dispose() { // Disable auto PiP when leaving so that we don't enter PiP on other screens. @@ -1109,10 +881,282 @@ abstract class VideoStoreBase with Store { // Explicitly stop latency tracker as defense-in-depth. // The Video widget also loads about:blank during disposal which clears // all JavaScript state, but this provides an extra safety layer. - try { - videoWebViewController.runJavaScript('window._latencyTracker?.stop()'); - } catch (e) { - // Ignore - page may already be unloading - } + _stopLatencyTracker(); } } + +// --------------------------------------------------------------------------- +// JS templates evaluated inside the Twitch player WebView. +// These are at the end of the file so the Dart code above stays readable. +// --------------------------------------------------------------------------- + +/// Bootstraps the Twitch player iframe: promise queue, async query selector, +/// and play/pause/PiP event listeners that post back through Flutter's +/// JavaScriptChannels. Run once per page load from [VideoStoreBase.initVideo]. +const _kInitVideoJs = r''' + // Promise queue with length limit and generation tracking + // Generation tracking prevents counter from going negative when queue resets + window._PROMISE_QUEUE = Promise.resolve(); + window._promiseQueueLength = 0; + window._promiseQueueGen = 0; + + window._queuePromise = (method) => { + window._promiseQueueLength++; + + // Reset queue if it gets too long (prevents memory accumulation) + if (window._promiseQueueLength > 30) { + window._PROMISE_QUEUE = Promise.resolve(); + window._promiseQueueLength = 1; + window._promiseQueueGen++; + } + + const myGen = window._promiseQueueGen; + + window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(async () => { + try { + await method(); + } catch (e) { + console.warn('Queue promise error:', e); + } finally { + // Only decrement if still same generation (not reset) + if (myGen === window._promiseQueueGen) { + window._promiseQueueLength--; + } + } + }); + + return window._PROMISE_QUEUE; + }; + window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => { + let element = document.querySelector(selector); + if (element) { + return resolve(element); + } + + let timeoutId; + const observer = new MutationObserver(() => { + element = document.querySelector(selector); + if (element) { + observer.disconnect(); + clearTimeout(timeoutId); + resolve(element); + } + }); + // Must use subtree to detect nested elements in the player + observer.observe(document.body, { childList: true, subtree: true }); + + // Always set timeout with default of 10 seconds + timeoutId = setTimeout(() => { + observer.disconnect(); + resolve(undefined); + }, timeout); + }); + + _queuePromise(async () => { + const videoElement = await _asyncQuerySelector("video"); + + // Null check in case element was not found within timeout + if (!videoElement) { + console.warn("Video element not found within timeout"); + return; + } + + // Cache for reuse in play/pause/PiP calls + window._videoEl = videoElement; + + // Prevent duplicate event listener registration + if (!videoElement._listenersAdded) { + videoElement._listenersAdded = true; + + videoElement.addEventListener("pause", () => { + VideoPause.postMessage("video paused"); + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + }); + videoElement.addEventListener("playing", () => { + VideoPlaying.postMessage("video playing"); + // Ensure video is unmuted and captions are hidden + videoElement.muted = false; + videoElement.volume = 1.0; + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + }); + + // Add PiP event listeners for iOS + videoElement.addEventListener("enterpictureinpicture", () => { + PipEntered.postMessage("pip entered"); + }); + videoElement.addEventListener("leavepictureinpicture", () => { + PipExited.postMessage("pip exited"); + }); + } + + if (!videoElement.paused) { + VideoPlaying.postMessage("video playing"); + // Ensure video is unmuted and captions are hidden + videoElement.muted = false; + videoElement.volume = 1.0; + if (videoElement.textTracks && videoElement.textTracks.length > 0) { + videoElement.textTracks[0].mode = "hidden"; + } + } + + // Explicitly attempt autoplay in case the player loaded paused + try { await videoElement.play(); } catch (e) {} + }); +'''; + +/// Periodic latency-tracker state machine running inside the Twitch player +/// WebView. Opens the stats panel, reads "Latency To Broadcaster", posts the +/// value through the `Latency` JavaScriptChannel, and closes the panel. +/// Pauses its cycle when the overlay is hidden (saves CPU). +const _kLatencyTrackerJs = r''' + window._latencyTracker ??= { + CYCLE_INTERVAL: 60000, + STATS_ACTIVE_TIME: 1500, + INITIAL_RETRY_INTERVAL: 3000, + MAX_INITIAL_RETRIES: 4, + + cycleCount: 0, + hasInitialLatency: false, + timeoutId: null, + isRunning: false, + overlayVisible: true, + isCycling: false, + + init() { + if (this.isRunning) return; + this.isRunning = true; + if (!this.isCycling && !this.timeoutId) { + this._cycle(); + } + }, + + stop() { + this.isRunning = false; + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }, + + setOverlayVisible(visible) { + this.overlayVisible = visible; + // Resume immediately when overlay becomes visible + if (visible && this.isRunning && !this.isCycling && !this.timeoutId) { + this._cycle(); + } + }, + + async _cycle() { + if (this.isCycling) return; + this.isCycling = true; + + try { + // Clear timeout ID synchronously at entry to prevent race conditions + // (e.g., setOverlayVisible calling _cycle while timeout is firing) + const currentTimeoutId = this.timeoutId; + this.timeoutId = null; + if (currentTimeoutId) { + clearTimeout(currentTimeoutId); + } + + if (!this.isRunning) return; + + // Skip cycle entirely if overlay not visible - major CPU savings + if (!this.overlayVisible) { + this.timeoutId = setTimeout(() => this._cycle(), 5000); + return; + } + + // Defer if another operation (e.g., quality selection) is using the settings menu + if (window._promiseQueueLength > 0) { + this.timeoutId = setTimeout(() => this._cycle(), 2000); + return; + } + + this.cycleCount++; + const cycleStart = Date.now(); + + await this._setStatsEnabled(true); + + // Wait longer on first cycle for stats to populate + const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; + await new Promise(resolve => setTimeout(resolve, waitTime)); + + this._readLatency(); + await this._setStatsEnabled(false); + + if (!this.isRunning) return; + + const totalActiveTime = Date.now() - cycleStart; + + // Calculate next interval + let nextInterval; + if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { + nextInterval = this.INITIAL_RETRY_INTERVAL; + } else { + nextInterval = this.CYCLE_INTERVAL - totalActiveTime; + } + + // Schedule next cycle + this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); + } finally { + this.isCycling = false; + } + }, + + async _setStatsEnabled(enable) { + try { + await _queuePromise(async () => { + const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); + if (!settingsBtn) return; + settingsBtn.click(); + + const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]'); + if (!advancedItem) { + settingsBtn.click(); + return; + } + advancedItem.click(); + + const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); + if (statsCheckbox && statsCheckbox.checked !== enable) { + statsCheckbox.click(); + } + + settingsBtn.click(); + }); + } catch (error) { + // Silently fail - stats panel may not be available + } + }, + + _readLatency() { + try { + const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]'); + if (latencyElement && latencyElement.textContent) { + let latencyText = latencyElement.textContent.trim(); + + // Convert to whole number with abbreviated unit: "4.69 sec." -> "5s" + const match = latencyText.match(/([0-9.]+)\s*sec/i); + if (match) { + const rounded = Math.round(parseFloat(match[1])); + latencyText = rounded + 's'; + this.hasInitialLatency = true; + } + + if (window.Latency) { + Latency.postMessage(latencyText); + } + } + } catch (error) { + // Silently fail + } + } + }; + + window._latencyTracker.init(); +'''; diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 30c79efc..c12540e5 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,6 +9,26 @@ part of 'video_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$VideoStore on VideoStoreBase, Store { + late final _$_loadingAtom = Atom( + name: 'VideoStoreBase._loading', + context: context, + ); + + bool get loading { + _$_loadingAtom.reportRead(); + return super._loading; + } + + @override + bool get _loading => loading; + + @override + set _loading(bool value) { + _$_loadingAtom.reportWrite(value, super._loading, () { + super._loading = value; + }); + } + late final _$_pausedAtom = Atom( name: 'VideoStoreBase._paused', context: context, diff --git a/lib/screens/channel/video/video_timing_constants.dart b/lib/screens/channel/video/video_timing_constants.dart new file mode 100644 index 00000000..3fe54344 --- /dev/null +++ b/lib/screens/channel/video/video_timing_constants.dart @@ -0,0 +1,67 @@ +/// Named durations and counts used by the video player stores. +/// Central owner so tuning values don't have to be hunted across files. +class VideoTimingConstants { + VideoTimingConstants._(); + + // Stall recovery + // + // Cross-layer ordering contract: iOS's native notification-stall recovery + // fires at ~1s, this Dart timer at 8s, and the Android native watchdog + // (VideoPlayerObserver.kt STALL_WATCHDOG_MS) at 12s. Correctness depends + // on 1s < stallDetectionDelay < 12s — Dart's attempt-1 seek must pre-empt + // the Android watchdog's heavier stop()/prepare() recovery, and iOS has + // no native watchdog so the Dart timer is the only recoverer for silent + // rate-drops there. Don't tune past either neighbor. + static const stallDetectionDelay = Duration(seconds: 8); + static const initRetryDelay = Duration(seconds: 3); + static const int maxRefreshAttempts = 3; + + /// Delay between stall-recovery full refreshes, indexed by attempt number + /// (0 = first attempt). Attempts beyond the list length fall through to + /// the error path via [maxRefreshAttempts]. + static const refreshBackoff = [ + Duration.zero, + Duration(seconds: 3), + Duration(seconds: 8), + ]; + + /// A "play" event arriving within this window after a previous one is + /// treated as a failed-recovery bounce rather than a fresh successful start. + static const shortPlayWindow = Duration(seconds: 10); + /// Number of short-play bounces required before surfacing a tailored error. + static const int shortPlayLoopThreshold = 3; + + // Latency / chat sync + static const latencyPollingInterval = Duration(seconds: 3); + static const int highLatencyThresholdSeconds = 30; + static const int chatSyncDriftToleranceSeconds = 2; + + /// Maximum consecutive high-latency interventions (seek/refresh) before + /// giving up on latency-driven recovery. Persistent high readings usually + /// mean device clock skew or a device that can't keep up — looping + /// recoveries forever makes playback worse, not better. + static const int maxHighLatencyRecoveries = 3; + + /// How long a quality switch may suppress stall classification before the + /// flag expires. A resolution-only switch never interrupts playback, so no + /// play/pause event arrives to clear it — without this backstop the flag + /// sticks for the whole session and disables stall recovery. + static const qualitySwitchGrace = Duration(seconds: 5); + + /// Delay before re-checking /streams when the first check reports offline. + /// Helix transiently drops live streams from results; a single failed + /// check isn't enough to tear down healthy playback. + static const offlineConfirmationDelay = Duration(seconds: 2); + + // Overlay + static const overlayAutoHide = Duration(seconds: 5); + static const overlayQuickHide = Duration(seconds: 3); + + // Stream info + static const streamInfoDebounce = Duration(seconds: 5); + static const webviewStreamInfoInterval = Duration(minutes: 1); + static const nativeStreamInfoInterval = Duration(seconds: 30); + + // WebView housekeeping + static const jsCleanupInterval = Duration(minutes: 10); +} diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 7bc450c7..64c32616 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -166,13 +166,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.favorite_border_rounded, - color: _homeStore.selectedIndex == 0 - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.favorite_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Following', tooltip: 'Following', @@ -180,15 +179,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 1 : 0) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.arrow_upward_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Top', tooltip: 'Top', @@ -196,15 +192,12 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: - _homeStore.selectedIndex == - (_authStore.isLoggedIn ? 2 : 1) - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), selectedIcon: Icon( Icons.search_rounded, - color: theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), label: 'Search', tooltip: 'Search', diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 7a55c3a5..b9c5efa7 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -5,6 +5,7 @@ import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/account/account_options.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/profile_picture.dart'; class ProfileCard extends StatelessWidget { @@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget { ); } if (authStore.isLoggedIn && authStore.user.details != null) { + final hasToken = authStore.gqlToken != null; return ListTile( leading: ProfilePicture( userLogin: authStore.user.details!.login, radius: 12, ), - title: Text(authStore.user.details!.displayName), + title: GestureDetector( + onTap: () { + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Web session', + message: hasToken + ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' + : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', + actions: hasToken + ? [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('OK'), + ), + ] + : [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + navigator.push( + MaterialPageRoute( + builder: (context) => LoginWebView(), + ), + ); + }, + child: const Text('Log in'), + ), + ], + ), + ); + }, + child: Text.rich( + TextSpan( + text: authStore.user.details!.displayName, + children: [ + const WidgetSpan(child: SizedBox(width: 6)), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded, + size: 16, + color: hasToken ? Colors.green : Colors.amber, + ), + ), + ], + ), + overflow: TextOverflow.ellipsis, + ), + ), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), ); diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 6216ac26..16b086ef 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -1,347 +1,389 @@ -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'; -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_slider.dart'; -import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; -import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; -import 'package:frosty/utils/context_extensions.dart'; -import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/section_header.dart'; -import 'package:frosty/widgets/settings_page_layout.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ChatSettings extends StatefulWidget { - final SettingsStore settingsStore; - - const ChatSettings({super.key, required this.settingsStore}); - - @override - State createState() => _ChatSettingsState(); -} - -class _ChatSettingsState extends State { - var showPreview = false; - - @override - Widget build(BuildContext context) { - final settingsStore = widget.settingsStore; - - return Observer( - builder: (context) => SettingsPageLayout( - children: [ - const SectionHeader('Message sizing', isFirst: true), - ExpansionTile( - title: const Text('Preview'), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - child: DefaultTextStyle( - style: DefaultTextStyle.of( - context, - ).style.copyWith(fontSize: settingsStore.fontSize), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', - height: - defaultBadgeSize * settingsStore.badgeScale, - width: - defaultBadgeSize * settingsStore.badgeScale, - ), - ), - const TextSpan(text: ' Badge and emote preview. '), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: FrostyCachedNetworkImage( - imageUrl: - 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', - height: - defaultEmoteSize * settingsStore.emoteScale, - width: - defaultEmoteSize * settingsStore.emoteScale, - ), - ), - ], - ), - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'Hello! Here\'s a text preview.', - textScaler: settingsStore.messageScale.textScaler, - ), - SizedBox(height: settingsStore.messageSpacing), - Text( - 'And another for spacing without an emote!', - textScaler: settingsStore.messageScale.textScaler, - ), - ], - ), - ), - ), - ], - ), - SettingsListSlider( - title: 'Badge scale', - trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', - value: settingsStore.badgeScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.badgeScale = newValue, - ), - SettingsListSlider( - title: 'Emote scale', - trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', - value: settingsStore.emoteScale, - min: 0.25, - max: 3.0, - divisions: 11, - onChanged: (newValue) => settingsStore.emoteScale = newValue, - ), - SettingsListSlider( - title: 'Message scale', - trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', - value: settingsStore.messageScale, - min: 0.5, - max: 2.0, - divisions: 6, - onChanged: (newValue) => settingsStore.messageScale = newValue, - ), - SettingsListSlider( - title: 'Message spacing', - trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px', - value: settingsStore.messageSpacing, - max: 30.0, - divisions: 15, - onChanged: (newValue) => settingsStore.messageSpacing = newValue, - ), - SettingsListSlider( - title: 'Font size', - trailing: settingsStore.fontSize.toInt().toString(), - value: settingsStore.fontSize, - min: 5, - max: 20, - divisions: 15, - onChanged: (newValue) => settingsStore.fontSize = newValue, - ), - const SectionHeader('Message appearance'), - SettingsListSwitch( - title: 'Show deleted messages', - subtitle: const Text( - 'Restores the original message of deleted messages.', - ), - value: settingsStore.showDeletedMessages, - onChanged: (newValue) => - settingsStore.showDeletedMessages = newValue, - ), - SettingsListSwitch( - title: 'Show message dividers', - value: settingsStore.showChatMessageDividers, - onChanged: (newValue) => - settingsStore.showChatMessageDividers = newValue, - ), - SettingsListSelect( - title: 'Message timestamps', - selectedOption: timestampNames[settingsStore.timestampType.index], - options: timestampNames, - onChanged: (newValue) => settingsStore.timestampType = - TimestampType.values[timestampNames.indexOf(newValue)], - ), - const SectionHeader('Delay and latency'), - SettingsListSwitch( - title: 'Sync message delay and stream latency', - 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'), - SettingsListSwitch( - title: 'Highlight first time chatters', - value: settingsStore.highlightFirstTimeChatter, - onChanged: (newValue) => - settingsStore.highlightFirstTimeChatter = newValue, - ), - SettingsListSwitch( - title: 'Show notices', - subtitle: const Text( - 'Shows notices such as subs and re-subs, announcements, and raids.', - ), - value: settingsStore.showUserNotices, - onChanged: (newValue) => settingsStore.showUserNotices = newValue, - ), - const SectionHeader('Layout'), - SettingsListSwitch( - title: 'Move emote menu button left', - subtitle: const Text( - 'Places the emote menu button on the left side to avoid accidental presses.', - ), - value: settingsStore.emoteMenuButtonOnLeft, - onChanged: (newValue) => - settingsStore.emoteMenuButtonOnLeft = newValue, - ), - SettingsListSwitch( - title: 'Persist chat tabs', - subtitle: const Text( - 'Secondary chat tabs are remembered when switching channels.', - ), - value: settingsStore.persistChatTabs, - onChanged: (newValue) { - settingsStore.persistChatTabs = newValue; - if (!newValue) { - settingsStore.secondaryTabs = []; - } - }, - ), - const SectionHeader('Landscape mode'), - SettingsListSwitch( - title: 'Move chat left', - value: settingsStore.landscapeChatLeftSide, - onChanged: (newValue) => - settingsStore.landscapeChatLeftSide = newValue, - ), - SettingsListSwitch( - title: 'Force vertical chat', - subtitle: const Text( - 'Intended for tablets and other larger displays.', - ), - value: settingsStore.landscapeForceVerticalChat, - onChanged: (newValue) => - settingsStore.landscapeForceVerticalChat = newValue, - ), - SettingsListSelect( - title: 'Fill notch side', - subtitle: - 'Overrides and fills the available space in devices with a display notch.', - selectedOption: - landscapeCutoutNames[settingsStore.landscapeCutout.index], - options: landscapeCutoutNames, - onChanged: (newValue) => settingsStore.landscapeCutout = - LandscapeCutoutType.values[landscapeCutoutNames.indexOf( - newValue, - )], - ), - SettingsListSlider( - title: 'Chat overlay opacity', - trailing: - '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', - subtitle: - 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.', - value: settingsStore.fullScreenChatOverlayOpacity, - divisions: 10, - onChanged: (newValue) => - settingsStore.fullScreenChatOverlayOpacity = newValue, - ), - const SectionHeader('Muted keywords'), - SettingsMutedWords(settingsStore: settingsStore), - SettingsListSwitch( - title: 'Match whole words', - subtitle: const Text( - 'Only matches whole words instead of partial matches.', - ), - value: settingsStore.matchWholeWord, - onChanged: (newValue) => settingsStore.matchWholeWord = newValue, - ), - const SectionHeader('Autocomplete'), - SettingsListSwitch( - title: 'Show autocomplete bar', - subtitle: const Text( - 'Shows a bar containing matching emotes and mentions while typing.', - ), - value: settingsStore.autocomplete, - onChanged: (newValue) => settingsStore.autocomplete = newValue, - ), - const SectionHeader('Emotes and badges'), - 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'), - 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: TextStyle( - color: context.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: context.colorScheme.primary, - ), - 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, - ), - ], - ), - ); - } -} +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'; +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_slider.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:frosty/screens/settings/widgets/settings_muted_words.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/settings_page_layout.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ChatSettings extends StatelessWidget { + final SettingsStore settingsStore; + + const ChatSettings({super.key, required this.settingsStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) => SettingsPageLayout( + children: [ + const SectionHeader('General', isFirst: true), + SettingsListSwitch( + title: 'Keep screen on', + subtitle: const Text( + 'Prevents the screen from sleeping while a channel is open.', + ), + value: settingsStore.keepScreenAwake, + onChanged: (newValue) => + settingsStore.keepScreenAwake = newValue, + ), + SettingsListSwitch( + title: 'Autocomplete', + subtitle: const Text( + 'Shows matching emotes and mentions while typing.', + ), + value: settingsStore.autocomplete, + onChanged: (newValue) => + settingsStore.autocomplete = newValue, + ), + SettingsListSwitch( + title: 'Load 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: TextStyle( + color: context.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: context.colorScheme.primary, + ), + 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, + ), + const SectionHeader('Message sizing'), + ExpansionTile( + title: const Text('Preview'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: DefaultTextStyle( + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: settingsStore.fontSize), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3', + height: + defaultBadgeSize * + settingsStore.badgeScale, + width: + defaultBadgeSize * + settingsStore.badgeScale, + ), + ), + const TextSpan( + text: ' Badge and emote preview. ', + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FrostyCachedNetworkImage( + imageUrl: + 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0', + height: + defaultEmoteSize * + settingsStore.emoteScale, + width: + defaultEmoteSize * + settingsStore.emoteScale, + ), + ), + ], + ), + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'Hello! Here\'s a text preview.', + textScaler: settingsStore.messageScale.textScaler, + ), + SizedBox(height: settingsStore.messageSpacing), + Text( + 'And another for spacing without an emote!', + textScaler: settingsStore.messageScale.textScaler, + ), + ], + ), + ), + ), + ], + ), + SettingsListSlider( + title: 'Badge scale', + trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x', + value: settingsStore.badgeScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.badgeScale = newValue, + ), + SettingsListSlider( + title: 'Emote scale', + trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x', + value: settingsStore.emoteScale, + min: 0.25, + max: 3.0, + divisions: 11, + onChanged: (newValue) => settingsStore.emoteScale = newValue, + ), + SettingsListSlider( + title: 'Message scale', + trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x', + value: settingsStore.messageScale, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (newValue) => settingsStore.messageScale = newValue, + ), + SettingsListSlider( + title: 'Message spacing', + trailing: + '${settingsStore.messageSpacing.toStringAsFixed(0)}px', + value: settingsStore.messageSpacing, + max: 30.0, + divisions: 15, + onChanged: (newValue) => + settingsStore.messageSpacing = newValue, + ), + SettingsListSlider( + title: 'Font size', + trailing: settingsStore.fontSize.toInt().toString(), + value: settingsStore.fontSize, + min: 5, + max: 20, + divisions: 15, + onChanged: (newValue) => settingsStore.fontSize = newValue, + ), + const SectionHeader('Message appearance'), + SettingsListSwitch( + title: 'Show deleted messages', + subtitle: const Text( + 'Restores the original message of deleted messages.', + ), + value: settingsStore.showDeletedMessages, + onChanged: (newValue) => + settingsStore.showDeletedMessages = newValue, + ), + SettingsListSwitch( + title: 'Show message dividers', + value: settingsStore.showChatMessageDividers, + onChanged: (newValue) => + settingsStore.showChatMessageDividers = newValue, + ), + SettingsListSelect( + title: 'Timestamps', + selectedOption: + timestampNames[settingsStore.timestampType.index], + options: timestampNames, + onChanged: (newValue) => settingsStore.timestampType = + TimestampType.values[timestampNames.indexOf(newValue)], + ), + SettingsListSwitch( + title: 'Show timestamps on historical messages', + subtitle: const Text( + 'Always show timestamps on messages loaded from chat history, ' + 'even when timestamps are disabled above.', + ), + value: settingsStore.showHistoricalTimestamps, + onChanged: (newValue) => + settingsStore.showHistoricalTimestamps = newValue, + ), + SettingsListSwitch( + title: 'Focus current channel', + subtitle: const Text( + 'Fades messages from other channels in shared chat and ' + 'merged views, so the current channel stands out.', + ), + value: settingsStore.focusCurrentChannel, + onChanged: (newValue) => + settingsStore.focusCurrentChannel = newValue, + ), + const SectionHeader('Delay'), + SettingsListSwitch( + title: 'Auto-sync chat delay', + value: settingsStore.autoSyncChatDelay, + onChanged: (newValue) => + settingsStore.autoSyncChatDelay = newValue, + ), + if (!settingsStore.autoSyncChatDelay) + SettingsListSlider( + title: 'Chat 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'), + SettingsListSwitch( + title: 'Highlight first-time chatters', + value: settingsStore.highlightFirstTimeChatter, + onChanged: (newValue) => + settingsStore.highlightFirstTimeChatter = newValue, + ), + SettingsListSwitch( + title: 'Show notices', + subtitle: const Text( + 'Shows notices such as subs, announcements, and raids.', + ), + value: settingsStore.showUserNotices, + onChanged: (newValue) => + settingsStore.showUserNotices = newValue, + ), + const SectionHeader('Layout'), + SettingsListSwitch( + title: 'Emote menu on left', + subtitle: const Text( + 'Places the emote menu button on the left side.', + ), + value: settingsStore.emoteMenuButtonOnLeft, + onChanged: (newValue) => + settingsStore.emoteMenuButtonOnLeft = newValue, + ), + SettingsListSwitch( + title: 'Remember chat tabs', + subtitle: const Text( + 'Secondary chat tabs are kept when switching channels.', + ), + value: settingsStore.persistChatTabs, + onChanged: (newValue) { + settingsStore.persistChatTabs = newValue; + if (!newValue) { + settingsStore.secondaryTabs = []; + } + }, + ), + const SectionHeader('Landscape'), + SettingsListSwitch( + title: 'Chat on left side', + value: settingsStore.landscapeChatLeftSide, + onChanged: (newValue) => + settingsStore.landscapeChatLeftSide = newValue, + ), + SettingsListSwitch( + title: 'Force vertical chat', + subtitle: const Text( + 'Intended for tablets and larger displays.', + ), + value: settingsStore.landscapeForceVerticalChat, + onChanged: (newValue) => + settingsStore.landscapeForceVerticalChat = newValue, + ), + SettingsListSelect( + title: 'Notch fill', + subtitle: + 'Fills the display cutout area on the selected side.', + selectedOption: + landscapeCutoutNames[settingsStore.landscapeCutout.index], + options: landscapeCutoutNames, + onChanged: (newValue) => settingsStore.landscapeCutout = + LandscapeCutoutType.values[landscapeCutoutNames.indexOf( + newValue, + )], + ), + SettingsListSlider( + title: 'Overlay chat opacity', + trailing: + '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%', + subtitle: + 'Opacity of the chat overlay in fullscreen mode.', + value: settingsStore.fullScreenChatOverlayOpacity, + divisions: 10, + onChanged: (newValue) => + settingsStore.fullScreenChatOverlayOpacity = newValue, + ), + const SectionHeader('Filtering'), + SettingsMutedWords(settingsStore: settingsStore), + SettingsListSwitch( + title: 'Match whole words', + subtitle: const Text( + 'Only matches whole words instead of partial matches.', + ), + value: settingsStore.matchWholeWord, + onChanged: (newValue) => + settingsStore.matchWholeWord = newValue, + ), + const SectionHeader('Emotes and badges'), + SettingsListSwitch( + title: 'Twitch emotes', + value: settingsStore.showTwitchEmotes, + onChanged: (newValue) => + settingsStore.showTwitchEmotes = newValue, + ), + SettingsListSwitch( + title: 'Twitch badges', + value: settingsStore.showTwitchBadges, + onChanged: (newValue) => + settingsStore.showTwitchBadges = newValue, + ), + SettingsListSwitch( + title: '7TV emotes', + value: settingsStore.show7TVEmotes, + onChanged: (newValue) => + settingsStore.show7TVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV emotes', + value: settingsStore.showBTTVEmotes, + onChanged: (newValue) => + settingsStore.showBTTVEmotes = newValue, + ), + SettingsListSwitch( + title: 'BTTV badges', + value: settingsStore.showBTTVBadges, + onChanged: (newValue) => + settingsStore.showBTTVBadges = newValue, + ), + SettingsListSwitch( + title: 'FFZ emotes', + value: settingsStore.showFFZEmotes, + onChanged: (newValue) => + settingsStore.showFFZEmotes = newValue, + ), + SettingsListSwitch( + title: 'FFZ badges', + value: settingsStore.showFFZBadges, + onChanged: (newValue) => + settingsStore.showFFZBadges = newValue, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index e67149f6..6b13f477 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget { AccentColorSetting(settingsStore: settingsStore), const SectionHeader('Stream card'), SettingsListSwitch( - title: 'Use large stream card', + title: 'Large stream cards', value: settingsStore.largeStreamCard, onChanged: (newValue) => settingsStore.largeStreamCard = newValue, ), SettingsListSwitch( - title: 'Show thumbnail', + title: 'Show thumbnails', value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 84d4a2ba..18548635 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:frosty/services/cookie_extractor.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -27,6 +28,9 @@ abstract class AuthBase with Store { /// The shared_preferences key for the user token. static const _userTokenKey = 'user_token'; + /// The secure storage key for the GQL web cookie token. + static const _gqlTokenKey = 'gql_token'; + /// The Twitch API service for making requests. final TwitchApi twitchApi; @@ -49,6 +53,10 @@ abstract class AuthBase with Store { @readonly String? _token; + /// Web cookie token extracted from the login WebView for GQL ad-free playback. + @readonly + String? _gqlToken; + /// Whether the user is logged in or not. @readonly var _isLoggedIn = false; @@ -83,6 +91,7 @@ abstract class AuthBase with Store { // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { + _extractGqlToken(); if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -211,12 +220,36 @@ abstract class AuthBase with Store { AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + Future _extractGqlToken() async { + try { + final token = await CookieExtractor.extractTwitchAuthToken(); + if (token != null) { + runInAction(() { + _gqlToken = token; + }); + await _storage.write(key: _gqlTokenKey, value: token); + } + } catch (e) { + debugPrint('GQL token extraction failed: $e'); + } + } + + /// Clears the stored web session token after Twitch rejects it, so the + /// profile card reflects the unlinked state and future stream loads skip + /// the doomed authenticated request. + @action + Future invalidateGqlToken() async { + _gqlToken = null; + await _storage.delete(key: _gqlTokenKey); + } + /// Initialize by retrieving a token if it does not already exist. @action Future init() async { try { // Read and set the currently stored user token, if any. _token = await _storage.read(key: _userTokenKey); + _gqlToken = await _storage.read(key: _gqlTokenKey); // If the token does not exist, get the default token. // Otherwise, log in. @@ -248,6 +281,10 @@ abstract class AuthBase with Store { if (user.details != null) { _isLoggedIn = true; _stopReconnectLoop(); + + // Recover a missed web session link — the WebView cookie store may + // still hold a valid auth-token from a previous login. + if (_gqlToken == null) _extractGqlToken(); } } @@ -293,9 +330,11 @@ abstract class AuthBase with Store { Future logout() async { try { _stopReconnectLoop(); - // Delete the existing user token. + // Delete the existing user token and GQL token. await _storage.delete(key: _userTokenKey); + await _storage.delete(key: _gqlTokenKey); _token = null; + _gqlToken = null; // Clear the user info. user.dispose(); diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index e3f1a04f..1b49807f 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -35,6 +35,26 @@ mixin _$AuthStore on AuthBase, Store { }); } + late final _$_gqlTokenAtom = Atom( + name: 'AuthBase._gqlToken', + context: context, + ); + + String? get gqlToken { + _$_gqlTokenAtom.reportRead(); + return super._gqlToken; + } + + @override + String? get _gqlToken => gqlToken; + + @override + set _gqlToken(String? value) { + _$_gqlTokenAtom.reportWrite(value, super._gqlToken, () { + super._gqlToken = value; + }); + } + late final _$_isLoggedInAtom = Atom( name: 'AuthBase._isLoggedIn', context: context, @@ -72,6 +92,18 @@ mixin _$AuthStore on AuthBase, Store { }); } + late final _$invalidateGqlTokenAsyncAction = AsyncAction( + 'AuthBase.invalidateGqlToken', + context: context, + ); + + @override + Future invalidateGqlToken() { + return _$invalidateGqlTokenAsyncAction.run( + () => super.invalidateGqlToken(), + ); + } + late final _$initAsyncAction = AsyncAction('AuthBase.init', context: context); @override diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 0ffe4c16..333f4e38 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultKeepScreenAwake = true; + static const defaultUseNativePlayer = false; - // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; static const defaultShowLatency = false; @@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store { @observable var useTextureRendering = defaultUseTextureRendering; - @JsonKey(defaultValue: defaultKeepScreenAwake) + @JsonKey(defaultValue: defaultUseNativePlayer) @observable - var keepScreenAwake = defaultKeepScreenAwake; + var useNativePlayer = defaultUseNativePlayer; // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; - keepScreenAwake = defaultKeepScreenAwake; + useNativePlayer = defaultUseNativePlayer; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; @@ -115,44 +114,27 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - // Message sizing defaults + + // -- Messages -- + // Sizing defaults static const defaultBadgeScale = 1.0; static const defaultEmoteScale = 1.0; static const defaultMessageScale = 1.0; static const defaultMessageSpacing = 8.0; static const defaultFontSize = 12.0; - // Message appearance defaults + // Appearance defaults static const defaultShowDeletedMessages = false; static const defaultShowChatMessageDividers = false; static const defaultTimestampType = TimestampType.disabled; - - // Delay defaults - static const defaultAutoSyncChatDelay = false; - static const defaultChatDelay = 0.0; + static const defaultShowHistoricalTimestamps = false; + static const defaultFocusCurrentChannel = false; // Alert defaults static const defaultHighlightFirstTimeChatter = true; static const defaultShowUserNotices = true; - // Layout defaults - static const defaultEmoteMenuButtonOnLeft = false; - - // Landscape mode defaults - static const defaultLandscapeChatLeftSide = false; - static const defaultLandscapeForceVerticalChat = false; - static const defaultLandscapeCutout = LandscapeCutoutType.none; - static const defaultChatWidth = 0.2; - static const defaultFullScreenChatOverlayOpacity = 0.5; - - // mute words defaults - static const defaultMutedWords = []; - static const defaultMatchWholeWord = true; - - // Autocomplete defaults - static const defaultAutocomplete = true; - - // Emotes and badges defaults + // -- Emotes & Badges -- static const defaultShowTwitchEmotes = true; static const defaultShowTwitchBadges = true; static const defaultShow7TVEmotes = true; @@ -161,14 +143,26 @@ abstract class _SettingsStoreBase with Store { static const defaultShowFFZEmotes = true; static const defaultShowFFZBadges = true; - // Recent messages defaults - static const defaultShowRecentMessages = false; - - // Chat tabs defaults + // -- Layout -- + static const defaultEmoteMenuButtonOnLeft = false; static const defaultPersistChatTabs = true; static const defaultSecondaryTabs = []; + static const defaultLandscapeChatLeftSide = false; + static const defaultLandscapeForceVerticalChat = false; + static const defaultLandscapeCutout = LandscapeCutoutType.none; + static const defaultChatWidth = 0.2; + static const defaultFullScreenChatOverlayOpacity = 0.5; - // Message sizing options + // -- Behavior -- + static const defaultKeepScreenAwake = true; + static const defaultAutocomplete = true; + static const defaultShowRecentMessages = false; + static const defaultAutoSyncChatDelay = false; + static const defaultChatDelay = 0.0; + static const defaultMutedWords = []; + static const defaultMatchWholeWord = true; + + // Messages options @JsonKey(defaultValue: defaultBadgeScale) @observable var badgeScale = defaultBadgeScale; @@ -189,7 +183,6 @@ abstract class _SettingsStoreBase with Store { @observable var fontSize = defaultFontSize; - // Message appearance options @JsonKey(defaultValue: defaultShowDeletedMessages) @observable var showDeletedMessages = defaultShowDeletedMessages; @@ -205,24 +198,14 @@ abstract class _SettingsStoreBase with Store { @observable var timestampType = defaultTimestampType; - // Delay options - @JsonKey(defaultValue: defaultAutoSyncChatDelay) + @JsonKey(defaultValue: defaultShowHistoricalTimestamps) @observable - var autoSyncChatDelay = defaultAutoSyncChatDelay; + var showHistoricalTimestamps = defaultShowHistoricalTimestamps; - @JsonKey(defaultValue: defaultChatDelay) + @JsonKey(defaultValue: defaultFocusCurrentChannel) @observable - var chatDelay = defaultChatDelay; + var focusCurrentChannel = defaultFocusCurrentChannel; - @JsonKey(includeFromJson: false, includeToJson: false) - @observable - var syncedChatDelay = defaultChatDelay; - - @computed - double get effectiveChatDelay => - autoSyncChatDelay ? syncedChatDelay : chatDelay; - - // Alert options @JsonKey(defaultValue: defaultHighlightFirstTimeChatter) @observable var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; @@ -231,38 +214,7 @@ abstract class _SettingsStoreBase with Store { @observable var showUserNotices = defaultShowUserNotices; - // Layout options - @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) - @observable - var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - // Landscape mode options - @JsonKey(defaultValue: defaultLandscapeChatLeftSide) - @observable - var landscapeChatLeftSide = defaultLandscapeChatLeftSide; - - @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) - @observable - var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - - @JsonKey(defaultValue: defaultLandscapeCutout) - @observable - var landscapeCutout = defaultLandscapeCutout; - - @JsonKey(defaultValue: defaultChatWidth) - @observable - var chatWidth = defaultChatWidth; - - @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) - @observable - var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - // Autocomplete options - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; - - // Emotes and badges + // Emotes & Badges options @JsonKey(defaultValue: defaultShowTwitchEmotes) @observable var showTwitchEmotes = defaultShowTwitchEmotes; @@ -291,12 +243,11 @@ abstract class _SettingsStoreBase with Store { @observable var showFFZBadges = defaultShowFFZBadges; - // Recent messages - @JsonKey(defaultValue: defaultShowRecentMessages) + // Layout options + @JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft) @observable - var showRecentMessages = defaultShowRecentMessages; + var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - // Chat tabs @JsonKey(defaultValue: defaultPersistChatTabs) @observable var persistChatTabs = defaultPersistChatTabs; @@ -305,6 +256,55 @@ abstract class _SettingsStoreBase with Store { @observable var secondaryTabs = defaultSecondaryTabs; + @JsonKey(defaultValue: defaultLandscapeChatLeftSide) + @observable + var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + + @JsonKey(defaultValue: defaultLandscapeForceVerticalChat) + @observable + var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + + @JsonKey(defaultValue: defaultChatWidth) + @observable + var chatWidth = defaultChatWidth; + + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior options + @JsonKey(defaultValue: defaultKeepScreenAwake) + @observable + var keepScreenAwake = defaultKeepScreenAwake; + + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + + @JsonKey(defaultValue: defaultShowRecentMessages) + @observable + var showRecentMessages = defaultShowRecentMessages; + + @JsonKey(defaultValue: defaultAutoSyncChatDelay) + @observable + var autoSyncChatDelay = defaultAutoSyncChatDelay; + + @JsonKey(defaultValue: defaultChatDelay) + @observable + var chatDelay = defaultChatDelay; + + @JsonKey(includeFromJson: false, includeToJson: false) + @observable + var syncedChatDelay = defaultChatDelay; + + @computed + double get effectiveChatDelay => + autoSyncChatDelay ? syncedChatDelay : chatDelay; + @JsonKey(defaultValue: defaultMutedWords) @observable List mutedWords = defaultMutedWords; @@ -315,36 +315,21 @@ abstract class _SettingsStoreBase with Store { @action void resetChatSettings() { + // Messages badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; messageScale = defaultMessageScale; messageSpacing = defaultMessageSpacing; fontSize = defaultFontSize; - showDeletedMessages = defaultShowDeletedMessages; showChatMessageDividers = defaultShowChatMessageDividers; timestampType = defaultTimestampType; - - autoSyncChatDelay = defaultAutoSyncChatDelay; - chatDelay = defaultChatDelay; - syncedChatDelay = defaultChatDelay; - + showHistoricalTimestamps = defaultShowHistoricalTimestamps; + focusCurrentChannel = defaultFocusCurrentChannel; highlightFirstTimeChatter = defaultHighlightFirstTimeChatter; showUserNotices = defaultShowUserNotices; - emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; - - landscapeChatLeftSide = defaultLandscapeChatLeftSide; - landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; - landscapeCutout = defaultLandscapeCutout; - chatWidth = defaultChatWidth; - fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; - - mutedWords = defaultMutedWords; - matchWholeWord = defaultMatchWholeWord; - - autocomplete = defaultAutocomplete; - + // Emotes & Badges showTwitchEmotes = defaultShowTwitchEmotes; showTwitchBadges = defaultShowTwitchBadges; show7TVEmotes = defaultShow7TVEmotes; @@ -353,10 +338,25 @@ abstract class _SettingsStoreBase with Store { showFFZEmotes = defaultShowFFZEmotes; showFFZBadges = defaultShowFFZBadges; - showRecentMessages = defaultShowRecentMessages; - + // Layout + emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft; persistChatTabs = defaultPersistChatTabs; secondaryTabs = defaultSecondaryTabs; + landscapeChatLeftSide = defaultLandscapeChatLeftSide; + landscapeForceVerticalChat = defaultLandscapeForceVerticalChat; + landscapeCutout = defaultLandscapeCutout; + chatWidth = defaultChatWidth; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + + // Behavior + keepScreenAwake = defaultKeepScreenAwake; + autocomplete = defaultAutocomplete; + showRecentMessages = defaultShowRecentMessages; + autoSyncChatDelay = defaultAutoSyncChatDelay; + chatDelay = defaultChatDelay; + syncedChatDelay = defaultChatDelay; + mutedWords = defaultMutedWords; + matchWholeWord = defaultMatchWholeWord; } // * Other settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 1955c3f5..2e713294 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson( ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true + ..useNativePlayer = json['useNativePlayer'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -41,12 +41,26 @@ SettingsStore _$SettingsStoreFromJson( unknownValue: TimestampType.disabled, ) ?? TimestampType.disabled - ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false - ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 + ..showHistoricalTimestamps = + json['showHistoricalTimestamps'] as bool? ?? false + ..focusCurrentChannel = json['focusCurrentChannel'] as bool? ?? false ..highlightFirstTimeChatter = json['highlightFirstTimeChatter'] as bool? ?? true ..showUserNotices = json['showUserNotices'] 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 ..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false + ..persistChatTabs = json['persistChatTabs'] as bool? ?? true + ..secondaryTabs = + (json['secondaryTabs'] as List?) + ?.map((e) => PersistedChatTab.fromJson(e as Map)) + .toList() ?? + [] ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false ..landscapeForceVerticalChat = json['landscapeForceVerticalChat'] as bool? ?? false @@ -59,21 +73,11 @@ SettingsStore _$SettingsStoreFromJson( ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2 ..fullScreenChatOverlayOpacity = (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 + ..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true ..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 - ..persistChatTabs = json['persistChatTabs'] as bool? ?? true - ..secondaryTabs = - (json['secondaryTabs'] as List?) - ?.map((e) => PersistedChatTab.fromJson(e as Map)) - .toList() ?? - [] + ..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false + ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 ..mutedWords = (json['mutedWords'] as List?) ?.map((e) => e as String) @@ -101,7 +105,7 @@ Map _$SettingsStoreToJson( 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, - 'keepScreenAwake': instance.keepScreenAwake, + 'useNativePlayer': instance.useNativePlayer, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -113,17 +117,10 @@ Map _$SettingsStoreToJson( 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, - 'autoSyncChatDelay': instance.autoSyncChatDelay, - 'chatDelay': instance.chatDelay, + 'showHistoricalTimestamps': instance.showHistoricalTimestamps, + 'focusCurrentChannel': instance.focusCurrentChannel, 'highlightFirstTimeChatter': instance.highlightFirstTimeChatter, 'showUserNotices': instance.showUserNotices, - 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, - 'landscapeChatLeftSide': instance.landscapeChatLeftSide, - 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, - 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, - 'chatWidth': instance.chatWidth, - 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, - 'autocomplete': instance.autocomplete, 'showTwitchEmotes': instance.showTwitchEmotes, 'showTwitchBadges': instance.showTwitchBadges, 'show7TVEmotes': instance.show7TVEmotes, @@ -131,9 +128,19 @@ Map _$SettingsStoreToJson( 'showBTTVBadges': instance.showBTTVBadges, 'showFFZEmotes': instance.showFFZEmotes, 'showFFZBadges': instance.showFFZBadges, - 'showRecentMessages': instance.showRecentMessages, + 'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft, 'persistChatTabs': instance.persistChatTabs, 'secondaryTabs': instance.secondaryTabs, + 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeForceVerticalChat': instance.landscapeForceVerticalChat, + 'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, + 'chatWidth': instance.chatWidth, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, + 'keepScreenAwake': instance.keepScreenAwake, + 'autocomplete': instance.autocomplete, + 'showRecentMessages': instance.showRecentMessages, + 'autoSyncChatDelay': instance.autoSyncChatDelay, + 'chatDelay': instance.chatDelay, 'mutedWords': instance.mutedWords, 'matchWholeWord': instance.matchWholeWord, 'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics, @@ -325,21 +332,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$keepScreenAwakeAtom = Atom( - name: '_SettingsStoreBase.keepScreenAwake', + late final _$useNativePlayerAtom = Atom( + name: '_SettingsStoreBase.useNativePlayer', context: context, ); @override - bool get keepScreenAwake { - _$keepScreenAwakeAtom.reportRead(); - return super.keepScreenAwake; + bool get useNativePlayer { + _$useNativePlayerAtom.reportRead(); + return super.useNativePlayer; } @override - set keepScreenAwake(bool value) { - _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { - super.keepScreenAwake = value; + set useNativePlayer(bool value) { + _$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () { + super.useNativePlayer = value; }); } @@ -545,57 +552,43 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$autoSyncChatDelayAtom = Atom( - name: '_SettingsStoreBase.autoSyncChatDelay', + late final _$showHistoricalTimestampsAtom = Atom( + name: '_SettingsStoreBase.showHistoricalTimestamps', context: context, ); @override - bool get autoSyncChatDelay { - _$autoSyncChatDelayAtom.reportRead(); - return super.autoSyncChatDelay; + bool get showHistoricalTimestamps { + _$showHistoricalTimestampsAtom.reportRead(); + return super.showHistoricalTimestamps; } @override - set autoSyncChatDelay(bool value) { - _$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () { - super.autoSyncChatDelay = value; - }); + set showHistoricalTimestamps(bool value) { + _$showHistoricalTimestampsAtom.reportWrite( + value, + super.showHistoricalTimestamps, + () { + super.showHistoricalTimestamps = value; + }, + ); } - late final _$chatDelayAtom = Atom( - name: '_SettingsStoreBase.chatDelay', + late final _$focusCurrentChannelAtom = Atom( + name: '_SettingsStoreBase.focusCurrentChannel', context: context, ); @override - double get chatDelay { - _$chatDelayAtom.reportRead(); - return super.chatDelay; + bool get focusCurrentChannel { + _$focusCurrentChannelAtom.reportRead(); + return super.focusCurrentChannel; } @override - set chatDelay(double value) { - _$chatDelayAtom.reportWrite(value, super.chatDelay, () { - super.chatDelay = value; - }); - } - - late final _$syncedChatDelayAtom = Atom( - name: '_SettingsStoreBase.syncedChatDelay', - context: context, - ); - - @override - double get syncedChatDelay { - _$syncedChatDelayAtom.reportRead(); - return super.syncedChatDelay; - } - - @override - set syncedChatDelay(double value) { - _$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () { - super.syncedChatDelay = value; + set focusCurrentChannel(bool value) { + _$focusCurrentChannelAtom.reportWrite(value, super.focusCurrentChannel, () { + super.focusCurrentChannel = value; }); } @@ -639,148 +632,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$emoteMenuButtonOnLeftAtom = Atom( - name: '_SettingsStoreBase.emoteMenuButtonOnLeft', - context: context, - ); - - @override - bool get emoteMenuButtonOnLeft { - _$emoteMenuButtonOnLeftAtom.reportRead(); - return super.emoteMenuButtonOnLeft; - } - - @override - set emoteMenuButtonOnLeft(bool value) { - _$emoteMenuButtonOnLeftAtom.reportWrite( - value, - super.emoteMenuButtonOnLeft, - () { - super.emoteMenuButtonOnLeft = value; - }, - ); - } - - late final _$landscapeChatLeftSideAtom = Atom( - name: '_SettingsStoreBase.landscapeChatLeftSide', - context: context, - ); - - @override - bool get landscapeChatLeftSide { - _$landscapeChatLeftSideAtom.reportRead(); - return super.landscapeChatLeftSide; - } - - @override - set landscapeChatLeftSide(bool value) { - _$landscapeChatLeftSideAtom.reportWrite( - value, - super.landscapeChatLeftSide, - () { - super.landscapeChatLeftSide = value; - }, - ); - } - - late final _$landscapeForceVerticalChatAtom = Atom( - name: '_SettingsStoreBase.landscapeForceVerticalChat', - context: context, - ); - - @override - bool get landscapeForceVerticalChat { - _$landscapeForceVerticalChatAtom.reportRead(); - return super.landscapeForceVerticalChat; - } - - @override - set landscapeForceVerticalChat(bool value) { - _$landscapeForceVerticalChatAtom.reportWrite( - value, - super.landscapeForceVerticalChat, - () { - super.landscapeForceVerticalChat = value; - }, - ); - } - - late final _$landscapeCutoutAtom = Atom( - name: '_SettingsStoreBase.landscapeCutout', - context: context, - ); - - @override - LandscapeCutoutType get landscapeCutout { - _$landscapeCutoutAtom.reportRead(); - return super.landscapeCutout; - } - - @override - set landscapeCutout(LandscapeCutoutType value) { - _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { - super.landscapeCutout = value; - }); - } - - late final _$chatWidthAtom = Atom( - name: '_SettingsStoreBase.chatWidth', - context: context, - ); - - @override - double get chatWidth { - _$chatWidthAtom.reportRead(); - return super.chatWidth; - } - - @override - set chatWidth(double value) { - _$chatWidthAtom.reportWrite(value, super.chatWidth, () { - super.chatWidth = value; - }); - } - - late final _$fullScreenChatOverlayOpacityAtom = Atom( - name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', - context: context, - ); - - @override - double get fullScreenChatOverlayOpacity { - _$fullScreenChatOverlayOpacityAtom.reportRead(); - return super.fullScreenChatOverlayOpacity; - } - - @override - set fullScreenChatOverlayOpacity(double value) { - _$fullScreenChatOverlayOpacityAtom.reportWrite( - value, - super.fullScreenChatOverlayOpacity, - () { - super.fullScreenChatOverlayOpacity = value; - }, - ); - } - - late final _$autocompleteAtom = Atom( - name: '_SettingsStoreBase.autocomplete', - context: context, - ); - - @override - bool get autocomplete { - _$autocompleteAtom.reportRead(); - return super.autocomplete; - } - - @override - set autocomplete(bool value) { - _$autocompleteAtom.reportWrite(value, super.autocomplete, () { - super.autocomplete = value; - }); - } - late final _$showTwitchEmotesAtom = Atom( name: '_SettingsStoreBase.showTwitchEmotes', context: context, @@ -907,22 +758,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$showRecentMessagesAtom = Atom( - name: '_SettingsStoreBase.showRecentMessages', + late final _$emoteMenuButtonOnLeftAtom = Atom( + name: '_SettingsStoreBase.emoteMenuButtonOnLeft', context: context, ); @override - bool get showRecentMessages { - _$showRecentMessagesAtom.reportRead(); - return super.showRecentMessages; + bool get emoteMenuButtonOnLeft { + _$emoteMenuButtonOnLeftAtom.reportRead(); + return super.emoteMenuButtonOnLeft; } @override - set showRecentMessages(bool value) { - _$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () { - super.showRecentMessages = value; - }); + set emoteMenuButtonOnLeft(bool value) { + _$emoteMenuButtonOnLeftAtom.reportWrite( + value, + super.emoteMenuButtonOnLeft, + () { + super.emoteMenuButtonOnLeft = value; + }, + ); } late final _$persistChatTabsAtom = Atom( @@ -961,6 +816,216 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$landscapeChatLeftSideAtom = Atom( + name: '_SettingsStoreBase.landscapeChatLeftSide', + context: context, + ); + + @override + bool get landscapeChatLeftSide { + _$landscapeChatLeftSideAtom.reportRead(); + return super.landscapeChatLeftSide; + } + + @override + set landscapeChatLeftSide(bool value) { + _$landscapeChatLeftSideAtom.reportWrite( + value, + super.landscapeChatLeftSide, + () { + super.landscapeChatLeftSide = value; + }, + ); + } + + late final _$landscapeForceVerticalChatAtom = Atom( + name: '_SettingsStoreBase.landscapeForceVerticalChat', + context: context, + ); + + @override + bool get landscapeForceVerticalChat { + _$landscapeForceVerticalChatAtom.reportRead(); + return super.landscapeForceVerticalChat; + } + + @override + set landscapeForceVerticalChat(bool value) { + _$landscapeForceVerticalChatAtom.reportWrite( + value, + super.landscapeForceVerticalChat, + () { + super.landscapeForceVerticalChat = value; + }, + ); + } + + late final _$landscapeCutoutAtom = Atom( + name: '_SettingsStoreBase.landscapeCutout', + context: context, + ); + + @override + LandscapeCutoutType get landscapeCutout { + _$landscapeCutoutAtom.reportRead(); + return super.landscapeCutout; + } + + @override + set landscapeCutout(LandscapeCutoutType value) { + _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { + super.landscapeCutout = value; + }); + } + + late final _$chatWidthAtom = Atom( + name: '_SettingsStoreBase.chatWidth', + context: context, + ); + + @override + double get chatWidth { + _$chatWidthAtom.reportRead(); + return super.chatWidth; + } + + @override + set chatWidth(double value) { + _$chatWidthAtom.reportWrite(value, super.chatWidth, () { + super.chatWidth = value; + }); + } + + late final _$fullScreenChatOverlayOpacityAtom = Atom( + name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', + context: context, + ); + + @override + double get fullScreenChatOverlayOpacity { + _$fullScreenChatOverlayOpacityAtom.reportRead(); + return super.fullScreenChatOverlayOpacity; + } + + @override + set fullScreenChatOverlayOpacity(double value) { + _$fullScreenChatOverlayOpacityAtom.reportWrite( + value, + super.fullScreenChatOverlayOpacity, + () { + super.fullScreenChatOverlayOpacity = value; + }, + ); + } + + late final _$keepScreenAwakeAtom = Atom( + name: '_SettingsStoreBase.keepScreenAwake', + context: context, + ); + + @override + bool get keepScreenAwake { + _$keepScreenAwakeAtom.reportRead(); + return super.keepScreenAwake; + } + + @override + set keepScreenAwake(bool value) { + _$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () { + super.keepScreenAwake = value; + }); + } + + late final _$autocompleteAtom = Atom( + name: '_SettingsStoreBase.autocomplete', + context: context, + ); + + @override + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; + } + + @override + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = 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 _$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, + ); + + @override + double get chatDelay { + _$chatDelayAtom.reportRead(); + return super.chatDelay; + } + + @override + set chatDelay(double value) { + _$chatDelayAtom.reportWrite(value, super.chatDelay, () { + super.chatDelay = value; + }); + } + + late final _$syncedChatDelayAtom = Atom( + name: '_SettingsStoreBase.syncedChatDelay', + context: context, + ); + + @override + double get syncedChatDelay { + _$syncedChatDelayAtom.reportRead(); + return super.syncedChatDelay; + } + + @override + set syncedChatDelay(double value) { + _$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () { + super.syncedChatDelay = value; + }); + } + late final _$mutedWordsAtom = Atom( name: '_SettingsStoreBase.mutedWords', context: context, @@ -1165,7 +1230,7 @@ launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, -keepScreenAwake: ${keepScreenAwake}, +useNativePlayer: ${useNativePlayer}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, @@ -1177,18 +1242,10 @@ fontSize: ${fontSize}, showDeletedMessages: ${showDeletedMessages}, showChatMessageDividers: ${showChatMessageDividers}, timestampType: ${timestampType}, -autoSyncChatDelay: ${autoSyncChatDelay}, -chatDelay: ${chatDelay}, -syncedChatDelay: ${syncedChatDelay}, +showHistoricalTimestamps: ${showHistoricalTimestamps}, +focusCurrentChannel: ${focusCurrentChannel}, highlightFirstTimeChatter: ${highlightFirstTimeChatter}, showUserNotices: ${showUserNotices}, -emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, -landscapeChatLeftSide: ${landscapeChatLeftSide}, -landscapeForceVerticalChat: ${landscapeForceVerticalChat}, -landscapeCutout: ${landscapeCutout}, -chatWidth: ${chatWidth}, -fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, -autocomplete: ${autocomplete}, showTwitchEmotes: ${showTwitchEmotes}, showTwitchBadges: ${showTwitchBadges}, show7TVEmotes: ${show7TVEmotes}, @@ -1196,9 +1253,20 @@ showBTTVEmotes: ${showBTTVEmotes}, showBTTVBadges: ${showBTTVBadges}, showFFZEmotes: ${showFFZEmotes}, showFFZBadges: ${showFFZBadges}, -showRecentMessages: ${showRecentMessages}, +emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft}, persistChatTabs: ${persistChatTabs}, secondaryTabs: ${secondaryTabs}, +landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeForceVerticalChat: ${landscapeForceVerticalChat}, +landscapeCutout: ${landscapeCutout}, +chatWidth: ${chatWidth}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, +keepScreenAwake: ${keepScreenAwake}, +autocomplete: ${autocomplete}, +showRecentMessages: ${showRecentMessages}, +autoSyncChatDelay: ${autoSyncChatDelay}, +chatDelay: ${chatDelay}, +syncedChatDelay: ${syncedChatDelay}, mutedWords: ${mutedWords}, matchWholeWord: ${matchWholeWord}, shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index d6b2b72b..4e2aedd4 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget { children: [ const SectionHeader('Player', isFirst: true), SettingsListSwitch( - title: 'Enable video', + title: 'Show video player', value: settingsStore.showVideo, onChanged: (newValue) => settingsStore.showVideo = newValue, ), + if (settingsStore.showVideo) + SettingsListSwitch( + title: 'Native player (experimental)', + subtitle: const Text( + 'More performant video player with auto picture-in-picture and quality selection.', + ), + value: settingsStore.useNativePlayer, + onChanged: (newValue) => + settingsStore.useNativePlayer = newValue, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', @@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useTextureRendering = newValue, ), - SettingsListSwitch( - title: 'Keep screen awake', - subtitle: const Text( - 'Prevents the screen from sleeping while a channel is open.', - ), - value: settingsStore.keepScreenAwake, - onChanged: (newValue) => settingsStore.keepScreenAwake = newValue, - ), const SectionHeader('Overlay'), SettingsListSwitch( title: 'Use custom video overlay', @@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.showOverlay = newValue, ), SettingsListSwitch( - title: 'Long-press player to toggle overlay', + title: 'Toggle overlay on long-press', subtitle: const Text( - 'Allows switching between Twitch\'s overlay and the custom overlay.', + 'Switch between Twitch\'s overlay and the custom overlay.', ), value: settingsStore.toggleableOverlay, - onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, + onChanged: (newValue) => + settingsStore.toggleableOverlay = newValue, ), SettingsListSwitch( title: 'Show latency', diff --git a/lib/services/cookie_extractor.dart b/lib/services/cookie_extractor.dart new file mode 100644 index 00000000..cca2b028 --- /dev/null +++ b/lib/services/cookie_extractor.dart @@ -0,0 +1,9 @@ +import 'package:flutter/services.dart'; + +class CookieExtractor { + static const _channel = MethodChannel('frosty/cookie_extractor'); + + static Future extractTwitchAuthToken() async { + return await _channel.invokeMethod('extractTwitchAuthToken'); + } +} diff --git a/lib/theme.dart b/lib/theme.dart index 06216a11..19eb4f80 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -39,23 +39,23 @@ class FrostyColors extends ThemeExtension { required this.overlayOnSurface, }); - static const light = FrostyColors( - highlightedMessage: Color(0xff9146ff), - emoteOnly: Color(0xFFFFB74D), - followersOnly: Color(0xFFF44336), - slowMode: Color(0xFF2196F3), - subOnly: Color(0xFF4CAF50), - uniqueMode: Color(0xFFAB47BC), + static FrostyColors light({required ColorScheme colorScheme}) => FrostyColors( + highlightedMessage: colorScheme.primary, + emoteOnly: const Color(0xFFFFB74D), + followersOnly: const Color(0xFFF44336), + slowMode: const Color(0xFF2196F3), + subOnly: const Color(0xFF4CAF50), + uniqueMode: const Color(0xFFAB47BC), overlayOnSurface: Colors.white, ); - static const dark = FrostyColors( - highlightedMessage: Color(0xff9146ff), - emoteOnly: Color(0xFFFFCC80), - followersOnly: Color(0xFFE57373), - slowMode: Color(0xFF64B5F6), - subOnly: Color(0xFF81C784), - uniqueMode: Color(0xFFBA68C8), + static FrostyColors dark({required ColorScheme colorScheme}) => FrostyColors( + highlightedMessage: colorScheme.primary, + emoteOnly: const Color(0xFFFFCC80), + followersOnly: const Color(0xFFE57373), + slowMode: const Color(0xFF64B5F6), + subOnly: const Color(0xFF81C784), + uniqueMode: const Color(0xFFBA68C8), overlayOnSurface: Colors.white, ); @@ -124,12 +124,17 @@ class FrostyThemes { return ThemeData( extensions: [ colorScheme.brightness == Brightness.dark - ? FrostyColors.dark - : FrostyColors.light, + ? FrostyColors.dark(colorScheme: colorScheme) + : FrostyColors.light(colorScheme: colorScheme), ], colorScheme: colorScheme, fontFamily: 'Inter', splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, + textSelectionTheme: TextSelectionThemeData( + cursorColor: colorScheme.primary, + selectionColor: colorScheme.primary.withValues(alpha: 0.3), + selectionHandleColor: colorScheme.primary, + ), scaffoldBackgroundColor: backgroundColor, bottomSheetTheme: BottomSheetThemeData( showDragHandle: true, @@ -139,7 +144,7 @@ class FrostyThemes { menuTheme: MenuThemeData( style: MenuStyle( padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: 4), + EdgeInsets.symmetric(vertical: 2), ), shape: WidgetStatePropertyAll( RoundedRectangleBorder( @@ -152,6 +157,9 @@ class FrostyThemes { menuButtonTheme: MenuButtonThemeData( style: MenuItemButton.styleFrom( visualDensity: VisualDensity.compact, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 12), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), dialogTheme: DialogThemeData( @@ -196,6 +204,23 @@ class FrostyThemes { ), ), ), + listTileTheme: ListTileThemeData( + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: -0.011, + height: 1.5, + ), + subtitleTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 13, + height: 1.4, + ), + leadingAndTrailingTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), navigationBarTheme: NavigationBarThemeData( elevation: 0, backgroundColor: backgroundColor, diff --git a/lib/utils/context_extensions.dart b/lib/utils/context_extensions.dart index f9cf83bf..f666821a 100644 --- a/lib/utils/context_extensions.dart +++ b/lib/utils/context_extensions.dart @@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/stores/global_assets_store.dart'; @@ -13,17 +14,17 @@ import 'package:provider/provider.dart'; class OrientationUtils { /// Gets the current orientation from the given context static Orientation getCurrentOrientation(BuildContext context) { - return MediaQuery.of(context).orientation; + return MediaQuery.orientationOf(context); } /// Returns true if the current orientation is portrait static bool isPortrait(BuildContext context) { - return MediaQuery.of(context).orientation == Orientation.portrait; + return MediaQuery.orientationOf(context) == Orientation.portrait; } /// Returns true if the current orientation is landscape static bool isLandscape(BuildContext context) { - return MediaQuery.of(context).orientation == Orientation.landscape; + return MediaQuery.orientationOf(context) == Orientation.landscape; } } @@ -55,22 +56,25 @@ extension ContextExtensions on BuildContext { // ===== SCREEN & LAYOUT ===== /// Gets the screen width - double get screenWidth => MediaQuery.of(this).size.width; + double get screenWidth => MediaQuery.sizeOf(this).width; /// Gets the screen height - double get screenHeight => MediaQuery.of(this).size.height; + double get screenHeight => MediaQuery.sizeOf(this).height; /// Gets the safe area padding top (commonly used) - double get safePaddingTop => MediaQuery.of(this).padding.top; + double get safePaddingTop => MediaQuery.paddingOf(this).top; /// Gets the safe area padding bottom (commonly used) - double get safePaddingBottom => MediaQuery.of(this).padding.bottom; + double get safePaddingBottom => MediaQuery.paddingOf(this).bottom; // ===== PROVIDER ACCESS ===== /// Gets TwitchApi from provider TwitchApi get twitchApi => read(); + /// Gets TwitchGqlApi from provider + TwitchGqlApi get twitchGqlApi => read(); + /// Gets AuthStore from provider AuthStore get authStore => read(); diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 7e3dcb78..1c6eb8a0 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -49,10 +49,10 @@ class ExpandableSectionHeader extends StatelessWidget { child: Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurface.withValues( + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( alpha: 0.6, ), ), @@ -63,7 +63,9 @@ class ExpandableSectionHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), child: Icon( Icons.keyboard_arrow_down, - color: context.colorScheme.onSurfaceVariant, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), size: 20, ), ), diff --git a/lib/widgets/live_indicator.dart b/lib/widgets/live_indicator.dart index cb6ce460..3ef42f18 100644 --- a/lib/widgets/live_indicator.dart +++ b/lib/widgets/live_indicator.dart @@ -138,57 +138,61 @@ class _LiveIndicatorState extends State ); } - return AnimatedBuilder( - animation: _pingController!, - builder: (context, child) { - final pingSize = widget.size * _pingScale!.value; - return SizedBox( - width: widget.size, - height: widget.size, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Ping ring rendered outside without affecting layout - Positioned.fill( - child: IgnorePointer( - child: OverflowBox( - maxWidth: pingSize, - maxHeight: pingSize, - child: Center( - child: Opacity( - opacity: _pingOpacity!.value, - child: Container( - width: pingSize, - height: pingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: widget.color.withValues( - alpha: _borderOpacity, + return SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Ping ring in its own RepaintBoundary so its 60fps animation + // doesn't dirty the overlay's compositing layer (expensive with + // Impeller + PlatformView). + Positioned.fill( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _pingController!, + builder: (context, child) { + final pingSize = widget.size * _pingScale!.value; + return IgnorePointer( + child: OverflowBox( + maxWidth: pingSize, + maxHeight: pingSize, + child: Center( + child: Opacity( + opacity: _pingOpacity!.value, + child: Container( + width: pingSize, + height: pingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: widget.color.withValues( + alpha: _borderOpacity, + ), + width: _borderWidth, ), - width: _borderWidth, ), ), ), ), ), - ), - ), + ); + }, ), - // Static center dot - Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: widget.color, - shape: BoxShape.circle, - ), - ), - ], + ), ), - ); - }, + // Static center dot + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ], + ), ); } } diff --git a/lib/widgets/measure_size.dart b/lib/widgets/measure_size.dart new file mode 100644 index 00000000..b7cba10d --- /dev/null +++ b/lib/widgets/measure_size.dart @@ -0,0 +1,46 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Reports the rendered size of [child] via [onChange] whenever it changes. +/// +/// Unlike `LayoutBuilder` (which exposes incoming constraints, not intrinsic +/// size), this measures the child's actual laid-out size. The callback is +/// deferred to a post-frame callback so listeners can safely mutate state +/// (e.g. a store observable) without triggering a build-phase mutation. +class MeasureSize extends SingleChildRenderObjectWidget { + const MeasureSize({ + super.key, + required this.onChange, + required Widget super.child, + }); + + final ValueChanged onChange; + + @override + RenderObject createRenderObject(BuildContext context) => + _MeasureSizeRenderObject(onChange); + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { + (renderObject as _MeasureSizeRenderObject).onChange = onChange; + } +} + +class _MeasureSizeRenderObject extends RenderProxyBox { + _MeasureSizeRenderObject(this.onChange); + + ValueChanged onChange; + Size? _oldSize; + + @override + void performLayout() { + super.performLayout(); + + final newSize = child?.size ?? Size.zero; + if (_oldSize == newSize) return; + _oldSize = newSize; + + // Defer out of the layout phase so the callback can mutate observed state. + WidgetsBinding.instance.addPostFrameCallback((_) => onChange(newSize)); + } +} diff --git a/lib/widgets/profile_picture.dart b/lib/widgets/profile_picture.dart index 3768428e..8580d66a 100644 --- a/lib/widgets/profile_picture.dart +++ b/lib/widgets/profile_picture.dart @@ -7,7 +7,11 @@ class ProfilePicture extends StatefulWidget { final String userLogin; final double radius; - const ProfilePicture({super.key, required this.userLogin, this.radius = 20}); + const ProfilePicture({ + super.key, + required this.userLogin, + this.radius = 20, + }); @override State createState() => _ProfilePictureState(); diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index 285244a6..40d4855a 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -36,10 +36,12 @@ class SectionHeader extends StatelessWidget { Text( text.toUpperCase(), style: TextStyle( - fontSize: fontSize ?? 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: context.colorScheme.onSurfaceVariant, + fontSize: fontSize ?? 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: context.colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), ), ], diff --git a/lib/widgets/settings_page_layout.dart b/lib/widgets/settings_page_layout.dart index 9b6ebd31..67041760 100644 --- a/lib/widgets/settings_page_layout.dart +++ b/lib/widgets/settings_page_layout.dart @@ -39,7 +39,7 @@ class _SettingsPageLayoutState extends State { // Responsive values based on orientation final listPadding = EdgeInsets.only( - top: isPortrait ? 116 : 92, + top: isPortrait ? 124 : 100, bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0, ); diff --git a/pubspec.lock b/pubspec.lock index 11a70b52..a2e24717 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,1500 +1,1533 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" - url: "https://pub.dev" - source: hosted - version: "92.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a - url: "https://pub.dev" - source: hosted - version: "1.3.67" - advanced_in_app_review: - dependency: "direct main" - description: - name: advanced_in_app_review - sha256: "06075938ce94fc6cc67e7b60b503504e329e5d7cb9900dea9768dba0fa89bded" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" - url: "https://pub.dev" - source: hosted - version: "9.0.0" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - app_links: - dependency: "direct main" - description: - name: app_links - sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" - url: "https://pub.dev" - source: hosted - version: "2.12.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" - url: "https://pub.dev" - source: hosted - version: "8.12.4" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b - url: "https://pub.dev" - source: hosted - version: "3.1.3" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" - url: "https://pub.dev" - source: hosted - version: "12.3.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.dev" - source: hosted - version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - extended_text_field: - dependency: "direct main" - description: - path: "." - ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" - resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" - url: "https://github.com/tommyxchow/extended_text_field.git" - source: git - version: "16.0.2" - extended_text_library: - dependency: transitive - description: - name: extended_text_library - sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" - url: "https://pub.dev" - source: hosted - version: "12.0.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - firebase_analytics: - dependency: "direct main" - description: - name: firebase_analytics - sha256: "8170273394694efdf567e7e30c26457ff346377a52eb679c278f97b36b786d70" - url: "https://pub.dev" - source: hosted - version: "12.1.3" - firebase_analytics_platform_interface: - dependency: transitive - description: - name: firebase_analytics_platform_interface - sha256: "2c25cd85f640a47fcc15e970a04a50470f0a4d0e76f23c5bc5ec93a3d48d8775" - url: "https://pub.dev" - source: hosted - version: "5.0.7" - firebase_analytics_web: - dependency: transitive - description: - name: firebase_analytics_web - sha256: e0eb2b21eccb18109af2c1d3154f9e69b45abf4816e3290a6251008e0503aca9 - url: "https://pub.dev" - source: hosted - version: "0.6.1+3" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb - url: "https://pub.dev" - source: hosted - version: "4.5.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" - url: "https://pub.dev" - source: hosted - version: "3.5.0" - firebase_crashlytics: - dependency: "direct main" - description: - name: firebase_crashlytics - sha256: "2a6dc88d762af01790a05ff0cf814f7d4020050e8c69dec01962d9ed5dc1a531" - url: "https://pub.dev" - source: hosted - version: "5.0.8" - firebase_crashlytics_platform_interface: - dependency: transitive - description: - name: firebase_crashlytics_platform_interface - sha256: "5fd59d76d691f370e42fd2b786d46078e69ed4126ca0d84b585119f55cd97937" - url: "https://pub.dev" - source: hosted - version: "3.8.18" - firebase_performance: - dependency: "direct main" - description: - name: firebase_performance - sha256: a372e9479b5be725cd6ef62488afc07e1afd9a66627096a0f9d8b59c0ed3ed0d - url: "https://pub.dev" - source: hosted - version: "0.11.1+5" - firebase_performance_platform_interface: - dependency: transitive - description: - name: firebase_performance_platform_interface - sha256: c8be195a97ae9dfe13f422c02edc9e4ef2fe16476bb182b65562afb0729ce29b - url: "https://pub.dev" - source: hosted - version: "0.1.6+5" - firebase_performance_web: - dependency: transitive - description: - name: firebase_performance_web - sha256: "04d8285e5ff3aafa4f5ab9176fc0c48776a4b2df5bc26f31b91f8cc3ab1de526" - url: "https://pub.dev" - source: hosted - version: "0.1.8+3" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_cache_manager: - dependency: "direct main" - description: - name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_markdown_plus: - dependency: "direct main" - description: - name: flutter_markdown_plus - sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" - url: "https://pub.dev" - source: hosted - version: "1.0.7" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_native_splash: - dependency: "direct dev" - description: - name: flutter_native_splash - sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" - url: "https://pub.dev" - source: hosted - version: "2.4.7" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" - url: "https://pub.dev" - source: hosted - version: "9.2.4" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 - url: "https://pub.dev" - source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - gtk: - dependency: transitive - description: - name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c - url: "https://pub.dev" - source: hosted - version: "2.1.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_mock_adapter: - dependency: "direct dev" - description: - name: http_mock_adapter - sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - in_app_review: - dependency: transitive - description: - name: in_app_review - sha256: ab26ac54dbd802896af78c670b265eaeab7ecddd6af4d0751e9604b60574817f - url: "https://pub.dev" - source: hosted - version: "2.0.11" - in_app_review_platform_interface: - dependency: transitive - description: - name: in_app_review_platform_interface - sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 - url: "https://pub.dev" - source: hosted - version: "2.0.5" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" - url: "https://pub.dev" - source: hosted - version: "6.13.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - markdown: - dependency: transitive - description: - name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" - url: "https://pub.dev" - source: hosted - version: "7.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: "25ea9a3ee69243d578fb6288721d8eeb18550d0391c32f896d377d994154bff2" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: "406051a76157fb0a562700110bd2bedd85c959b1cd2d3df740c706bc07539c33" - url: "https://pub.dev" - source: hosted - version: "2.7.6" - mocktail: - dependency: "direct dev" - description: - name: mocktail - sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - native_device_orientation: - dependency: "direct main" - description: - name: native_device_orientation - sha256: "711aabfd7c67396f6562437cba078d17291f8b7c454c2fbc9739c9d7141b041d" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" - url: "https://pub.dev" - source: hosted - version: "0.17.5" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d - url: "https://pub.dev" - source: hosted - version: "9.0.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e - url: "https://pub.dev" - source: hosted - version: "2.2.22" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - photo_view: - dependency: "direct main" - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" - url: "https://pub.dev" - source: hosted - version: "2.4.21" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - simple_icons: - dependency: "direct main" - description: - name: simple_icons - sha256: "21246ffcaaf9f560695596093c2ecf9fa763fd112a15916fbe7d282932430bae" - url: "https://pub.dev" - source: hosted - version: "16.23.0" - simple_pip_mode: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "0da5255545e10b18883c737c9eca3ec619ccc2f6" - url: "https://github.com/tommyxchow/simple_pip_mode_flutter" - source: git - version: "1.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" - url: "https://pub.dev" - source: hosted - version: "1.3.10" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 - url: "https://pub.dev" - source: hosted - version: "2.4.2+2" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 - url: "https://pub.dev" - source: hosted - version: "3.4.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.dev" - source: hosted - version: "6.3.28" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f - url: "https://pub.dev" - source: hosted - version: "2.4.2" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - wakelock_plus: - dependency: "direct main" - description: - name: wakelock_plus - sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.dev" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: "direct main" - description: - name: webview_flutter_android - sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24" - url: "https://pub.dev" - source: hosted - version: "4.10.13" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" - url: "https://pub.dev" - source: hosted - version: "2.14.0" - webview_flutter_wkwebview: - dependency: "direct main" - description: - name: webview_flutter_wkwebview - sha256: fc0af89d403e1c053f03d023d97550412fa79f35332e2939514c82e6fe633198 - url: "https://pub.dev" - source: hosted - version: "3.23.8" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "0d1f0adfabbab9f46a1a80ce84a4d8b852b6e4dbf53ce413b30e0cf7d3631b71" + url: "https://pub.dev" + source: hosted + version: "1.3.72" + advanced_in_app_review: + dependency: "direct main" + description: + name: advanced_in_app_review + sha256: b1d34aa42a7045458364c4f114e956ed6b08efcd92cddf8bf76c51cc1e58ddd1 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: a350a5b37579b7227aaf9a59c07114617cd4283852e193f743b2b3d2d7483c18 + url: "https://pub.dev" + source: hosted + version: "7.1.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + better_native_video_player: + dependency: "direct main" + description: + path: "." + ref: frosty-patches + resolved-ref: "6460859318b7b44034fe44016cddff02d0bd4c0a" + url: "https://github.com/tommyxchow/flutter-native-video-player.git" + source: git + version: "0.4.11" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + url: "https://pub.dev" + source: hosted + version: "3.1.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + dismissible_page: + dependency: transitive + description: + name: dismissible_page + sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + extended_text_field: + dependency: "direct main" + description: + path: "." + ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + url: "https://github.com/tommyxchow/extended_text_field.git" + source: git + version: "16.0.2" + extended_text_library: + dependency: transitive + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "1f68d8a30265149035e9bb0b73962e43f3bf2debef4a7ae2d1d588361d5858a9" + url: "https://pub.dev" + source: hosted + version: "12.4.2" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "65f97fb923f036d487aa95be99eab90e0f8f636e4783c94deeb52c0e6f5dbae7" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: ec49a95c6abaadbd6a4327cc2680d911f86e62fdf6f2c96a3a326603410e2abf + url: "https://pub.dev" + source: hosted + version: "0.6.1+8" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9 + url: "https://pub.dev" + source: hosted + version: "4.10.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8" + url: "https://pub.dev" + source: hosted + version: "3.8.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "5d111db2b40426e76e2da95d133c19a9dbcf6500c4e9b3785c68da51fb3f6602" + url: "https://pub.dev" + source: hosted + version: "5.2.3" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "5bf4a83a1b9e0a5218f6d6491227440dd659242a55b16c279de0b8839791539a" + url: "https://pub.dev" + source: hosted + version: "3.8.23" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: a6b88d6933e02e4ba71f0641121dd7024f036b32a9929a01b638b3696a98e9ce + url: "https://pub.dev" + source: hosted + version: "0.11.4+2" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: "6b9912709d315f4f2e0c87ddada8fe4f4d1b06aa74df52a8ced19fed087f0591" + url: "https://pub.dev" + source: hosted + version: "0.2.0+2" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "5ccf0a4511cd477406fe4c02ee2cabee5bd7d3c9e8cc5effdba5dcbd46b90064" + url: "https://pub.dev" + source: hosted + version: "0.1.8+8" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + floating: + dependency: transitive + description: + name: floating + sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + flutter_mobx: + dependency: "direct main" + description: + name: flutter_mobx + sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" + url: "https://pub.dev" + source: hosted + version: "2.4.8" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + url: "https://pub.dev" + source: hosted + version: "4.9.1" + in_app_review: + dependency: transitive + description: + name: in_app_review + sha256: "364db0c160b37fe7fad9cdaa18f968473924b17368869010af902760421b6bf8" + url: "https://pub.dev" + source: hosted + version: "2.0.12" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + url: "https://pub.dev" + source: hosted + version: "6.14.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mobx: + dependency: "direct main" + description: + name: mobx + sha256: "25ea9a3ee69243d578fb6288721d8eeb18550d0391c32f896d377d994154bff2" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + mobx_codegen: + dependency: "direct dev" + description: + name: mobx_codegen + sha256: "6529bcae074838004b58ad01fbe4966be182a176aacfb723686985986ebac19f" + url: "https://pub.dev" + source: hosted + version: "2.7.7" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + native_device_orientation: + dependency: "direct main" + description: + name: native_device_orientation + sha256: "4751bd8648a1e4a22c2cf3e8035eb0d667f8f6d82f9544b2f7b1bc12de42f0a4" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + url: "https://pub.dev" + source: hosted + version: "2.4.26" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + simple_icons: + dependency: "direct main" + description: + name: simple_icons + sha256: "21246ffcaaf9f560695596093c2ecf9fa763fd112a15916fbe7d282932430bae" + url: "https://pub.dev" + source: hosted + version: "16.23.0" + simple_pip_mode: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "0da5255545e10b18883c737c9eca3ec619ccc2f6" + url: "https://github.com/tommyxchow/simple_pip_mode_flutter" + source: git + version: "1.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014 + url: "https://pub.dev" + source: hosted + version: "2.5.9" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + url: "https://pub.dev" + source: hosted + version: "1.2.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: "direct main" + description: + name: webview_flutter_android + sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: "direct main" + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index f5f69184..6ecbaa06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,10 @@ dependencies: url: https://github.com/tommyxchow/extended_text_field.git ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89 native_device_orientation: ^2.1.0 + better_native_video_player: + git: + url: https://github.com/tommyxchow/flutter-native-video-player.git + ref: frosty-patches dev_dependencies: flutter_test: diff --git a/test/models/playback_access_token_test.dart b/test/models/playback_access_token_test.dart new file mode 100644 index 00000000..fb09a3fa --- /dev/null +++ b/test/models/playback_access_token_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +Map gqlResponse({required String value, String sig = 'sig'}) => + { + 'data': { + 'streamPlaybackAccessToken': {'value': value, 'signature': sig}, + }, + }; + +void main() { + group('PlaybackAccessToken.fromGqlResponse', () { + test('parses hide_ads and restricted bitrates from token payload', () { + final value = jsonEncode({ + 'hide_ads': true, + 'chansub': { + 'restricted_bitrates': [ + '1080p60', + '720p60', + 'chunked', + 'archives', + 'sub_archives', + 'live', + 'audio_only', + ], + }, + }); + + final token = PlaybackAccessToken.fromGqlResponse( + gqlResponse(value: value), + ); + + expect(token.value, value); + expect(token.signature, 'sig'); + expect(token.hideAds, isTrue); + // chunked/archives/sub_archives/live are playlist-internal names, not + // real qualities; names merely containing those words must survive. + expect(token.restrictedQualities, [ + '1080p60', + '720p60', + 'audio_only', + ]); + }); + + test('does not filter qualities that merely contain reserved words', () { + final value = jsonEncode({ + 'chansub': { + 'restricted_bitrates': ['1080p60_live', 'olive', 'rearchives'], + }, + }); + + final token = PlaybackAccessToken.fromGqlResponse( + gqlResponse(value: value), + ); + + expect(token.restrictedQualities, ['1080p60_live', 'olive', 'rearchives']); + }); + + test('survives a non-JSON token payload', () { + final token = PlaybackAccessToken.fromGqlResponse( + gqlResponse(value: 'not-json'), + ); + + expect(token.value, 'not-json'); + expect(token.hideAds, isFalse); + expect(token.restrictedQualities, isEmpty); + }); + + test('throws FormatException when token is missing', () { + expect( + () => PlaybackAccessToken.fromGqlResponse({'data': null}), + throwsFormatException, + ); + }); + }); +} diff --git a/test/screens/channel/video/chat_latency_sync_test.dart b/test/screens/channel/video/chat_latency_sync_test.dart new file mode 100644 index 00000000..6d9a49fb --- /dev/null +++ b/test/screens/channel/video/chat_latency_sync_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +void main() { + // Reference the real constant so assertions track config changes rather + // than a hardcoded magic number. + final tolerance = + VideoTimingConstants.chatSyncDriftToleranceSeconds.toDouble(); + + late SettingsStore settingsStore; + late ChatLatencySync sync; + + setUp(() { + settingsStore = SettingsStore.fromJson({}); + sync = ChatLatencySync(settingsStore: settingsStore); + }); + + group('ChatLatencySync reset', () { + test('zeroes syncedChatDelay when autoSyncChatDelay is enabled', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 7.5; + + sync.reset(); + + expect(settingsStore.syncedChatDelay, 0.0); + }); + + test('is a no-op when autoSyncChatDelay is disabled', () { + settingsStore.autoSyncChatDelay = false; + settingsStore.syncedChatDelay = 7.5; + + sync.reset(); + + expect(settingsStore.syncedChatDelay, 7.5); + }); + }); + + group('ChatLatencySync report', () { + test('does nothing when autoSyncChatDelay is disabled', () { + settingsStore.autoSyncChatDelay = false; + settingsStore.syncedChatDelay = 0.0; + + sync.report(12.0); + + expect(settingsStore.syncedChatDelay, 0.0); + }); + + test('writes the first measurement when current delay is zero', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 0.0; + + // Even a tiny value writes because the current value is exactly 0. + sync.report(0.5); + + expect(settingsStore.syncedChatDelay, 0.5); + }); + + test('writes when drift exceeds the tolerance', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 5.0; + + final report = 5.0 + tolerance + 1.0; + sync.report(report); + + expect(settingsStore.syncedChatDelay, report); + }); + + test('does not write when drift is within the tolerance', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 5.0; + + // Strictly inside the tolerance band. + sync.report(5.0 + (tolerance - 0.5)); + + expect(settingsStore.syncedChatDelay, 5.0); + }); + + test('does not write when drift is exactly at the tolerance', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 5.0; + + // The code uses strictly-greater-than (`>`), so a drift equal to the + // tolerance must NOT write. + sync.report(5.0 + tolerance); + + expect(settingsStore.syncedChatDelay, 5.0); + }); + + test('writes when drift just exceeds the tolerance', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 5.0; + + final report = 5.0 + tolerance + 0.1; + sync.report(report); + + expect(settingsStore.syncedChatDelay, report); + }); + + test('writes when a downward drift exceeds the tolerance', () { + settingsStore.autoSyncChatDelay = true; + settingsStore.syncedChatDelay = 10.0; + + final report = 10.0 - tolerance - 1.0; + sync.report(report); + + expect(settingsStore.syncedChatDelay, report); + }); + }); +} diff --git a/test/screens/channel/video/stream_info_poller_test.dart b/test/screens/channel/video/stream_info_poller_test.dart new file mode 100644 index 00000000..935a98ad --- /dev/null +++ b/test/screens/channel/video/stream_info_poller_test.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/screens/channel/video/stream_info_poller.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../fixtures/api_responses.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +void main() { + const userLogin = 'testuser'; + const userId = '12345'; + + late MockTwitchApi api; + + // Fresh fixtures per test so nothing leaks between cases. + StreamTwitch buildStream() => StreamTwitch.fromJson( + (twitchStreamResponse['data']! as List).first as Map, + ); + + Channel buildChannel() => Channel.fromJson( + (twitchChannelResponse['data']! as List).first as Map, + ); + + // Fresh poller per test so _lastUpdate / _inFlight debounce state never + // carries over between tests. + StreamInfoPoller buildPoller() => + StreamInfoPoller(twitchApi: api, userLogin: userLogin, userId: userId); + + setUp(() { + api = MockTwitchApi(); + }); + + group('live', () { + test('returns stream when getStream succeeds', () async { + when( + () => api.getStream(userLogin: any(named: 'userLogin')), + ).thenAnswer((_) async => buildStream()); + + final poller = buildPoller(); + final result = await poller.fetch(); + + expect(result, isNotNull); + expect(result!.stream, isNotNull); + expect(result.stream!.userName, 'TestUser'); + expect(result.offlineChannel, isNull); + + verify(() => api.getStream(userLogin: userLogin)).called(1); + verifyNever(() => api.getChannel(userId: any(named: 'userId'))); + }); + }); + + group('debounce', () { + test( + 'second fetch within debounce window returns null, forceUpdate bypasses', + () async { + when( + () => api.getStream(userLogin: any(named: 'userLogin')), + ).thenAnswer((_) async => buildStream()); + + final poller = buildPoller(); + + // First fetch resolves with a real result. + final first = await poller.fetch(); + expect(first, isNotNull); + expect(first!.stream, isNotNull); + + // Immediate second fetch (no forceUpdate) is inside the 5s debounce + // window -> debounced -> null. + final second = await poller.fetch(); + expect(second, isNull); + + // forceUpdate bypasses the debounce -> a fresh result. + final forced = await poller.fetch(forceUpdate: true); + expect(forced, isNotNull); + expect(forced!.stream, isNotNull); + + // Two real fetches (first + forced); the debounced one issued no call. + verify(() => api.getStream(userLogin: userLogin)).called(2); + }, + ); + }); + + group('in-flight dedup', () { + test( + 'concurrent fetches await the same request and call getStream once', + () async { + final completer = Completer(); + when( + () => api.getStream(userLogin: any(named: 'userLogin')), + ).thenAnswer((_) => completer.future); + + final poller = buildPoller(); + + // Fire both without awaiting so the second sees the in-flight future. + final firstFuture = poller.fetch(); + final secondFuture = poller.fetch(); + + // Now let the underlying request complete. + completer.complete(buildStream()); + + final first = await firstFuture; + final second = await secondFuture; + + expect(first, isNotNull); + expect(second, isNotNull); + // Same deduplicated result instance. + expect(identical(first, second), isTrue); + expect(first!.stream, isNotNull); + + verify(() => api.getStream(userLogin: userLogin)).called(1); + }, + ); + }); + + group('second-chance retry', () { + test( + 'getStream fails once then succeeds on retry -> stream, no getChannel', + () async { + var calls = 0; + when(() => api.getStream(userLogin: any(named: 'userLogin'))).thenAnswer( + (_) async { + calls++; + if (calls == 1) throw Exception('transient drop'); + return buildStream(); + }, + ); + + final poller = buildPoller(); + final result = await poller.fetch(); + + expect(result, isNotNull); + expect(result!.stream, isNotNull); + expect(result.offlineChannel, isNull); + + verify(() => api.getStream(userLogin: userLogin)).called(2); + verifyNever(() => api.getChannel(userId: any(named: 'userId'))); + }, + ); + }); + + group('offline', () { + test( + 'getStream fails twice, getChannel succeeds -> offlineChannel', + () async { + when( + () => api.getStream(userLogin: any(named: 'userLogin')), + ).thenThrow(Exception('offline')); + when( + () => api.getChannel(userId: any(named: 'userId')), + ).thenAnswer((_) async => buildChannel()); + + final poller = buildPoller(); + final result = await poller.fetch(); + + expect(result, isNotNull); + expect(result!.stream, isNull); + expect(result.offlineChannel, isNotNull); + expect(result.offlineChannel!.broadcasterName, 'TestUser'); + + verify(() => api.getStream(userLogin: userLogin)).called(2); + verify(() => api.getChannel(userId: userId)).called(1); + }, + ); + }); + + group('total failure', () { + test( + 'getStream fails twice and getChannel fails -> empty result', + () async { + when( + () => api.getStream(userLogin: any(named: 'userLogin')), + ).thenThrow(Exception('stream boom')); + when( + () => api.getChannel(userId: any(named: 'userId')), + ).thenThrow(Exception('channel boom')); + + final poller = buildPoller(); + final result = await poller.fetch(); + + expect(result, isNotNull); + expect(result!.stream, isNull); + expect(result.offlineChannel, isNull); + + verify(() => api.getStream(userLogin: userLogin)).called(2); + verify(() => api.getChannel(userId: userId)).called(1); + }, + ); + }); +} diff --git a/test/screens/channel/video/video_timing_constants_test.dart b/test/screens/channel/video/video_timing_constants_test.dart new file mode 100644 index 00000000..9632ee94 --- /dev/null +++ b/test/screens/channel/video/video_timing_constants_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Regression guard for the cross-layer ordering invariants documented in +/// [VideoTimingConstants]. These constants are tuned against native code in +/// two other layers (iOS notification-stall recovery and the Android +/// VideoPlayerObserver watchdog) that are NOT visible from Dart. If someone +/// retunes a value past a safe bound, these tests fail with a message quoting +/// the contract so the regression is self-explanatory. +void main() { + group('VideoTimingConstants stall-timer ordering', () { + test( + 'stallDetectionDelay sits between iOS ~1s recovery and Android 12s watchdog', + () { + final seconds = VideoTimingConstants.stallDetectionDelay.inSeconds; + + expect( + seconds, + greaterThan(1), + reason: + 'Contract: 1s < stallDetectionDelay. iOS native notification-stall ' + 'recovery fires at ~1s; the Dart timer must not race ahead of it.', + ); + expect( + seconds, + lessThan(12), + reason: + 'Contract: stallDetectionDelay < 12s. The Android native watchdog ' + '(VideoPlayerObserver.kt STALL_WATCHDOG_MS) fires at 12s with a ' + 'heavier stop()/prepare() recovery. Dart\'s attempt-1 seek must ' + 'pre-empt it, so it must fire first.', + ); + }, + ); + }); + + group('VideoTimingConstants refreshBackoff', () { + test('is non-empty and starts at Duration.zero', () { + expect(VideoTimingConstants.refreshBackoff, isNotEmpty); + expect( + VideoTimingConstants.refreshBackoff.first, + Duration.zero, + reason: + 'First refresh attempt should be immediate (0 = first attempt per ' + 'the doc comment).', + ); + }); + + test('covers every refresh attempt up to maxRefreshAttempts', () { + // The comment states attempts beyond the list length fall through to the + // error path via [maxRefreshAttempts]. So the list must be long enough to + // back off every attempt before that fall-through, i.e. cover all + // maxRefreshAttempts attempts. + expect( + VideoTimingConstants.refreshBackoff.length, + greaterThanOrEqualTo(VideoTimingConstants.maxRefreshAttempts), + reason: + 'refreshBackoff must index every attempt up to maxRefreshAttempts ' + '(${VideoTimingConstants.maxRefreshAttempts}); a shorter list would ' + 'drop a backoff and the attempt would fall through to the error ' + 'path prematurely.', + ); + }); + + test('delays are monotonically non-decreasing', () { + final backoff = VideoTimingConstants.refreshBackoff; + for (var i = 1; i < backoff.length; i++) { + expect( + backoff[i], + greaterThanOrEqualTo(backoff[i - 1]), + reason: + 'Backoff must not shrink between attempts: index $i ' + '(${backoff[i]}) < index ${i - 1} (${backoff[i - 1]}). ' + 'Later retries should wait at least as long as earlier ones.', + ); + } + }); + }); + + group('VideoTimingConstants latency vs chat-sync tolerance', () { + test('highLatencyThreshold is much greater than chatSyncDriftTolerance', () { + expect( + VideoTimingConstants.highLatencyThresholdSeconds, + greaterThan(VideoTimingConstants.chatSyncDriftToleranceSeconds), + reason: + 'High-latency intervention (seek/refresh) must only trigger far ' + 'past the chat-sync drift tolerance ' + '(${VideoTimingConstants.chatSyncDriftToleranceSeconds}s); otherwise ' + 'normal sync drift would be misread as a latency problem and trigger ' + 'recovery.', + ); + }); + + test('highLatencyThreshold sits comfortably above normal live latency', () { + // Normal live latency is well under ~15s; the threshold must clear that + // so routine live playback is never flagged as high-latency. + expect( + VideoTimingConstants.highLatencyThresholdSeconds, + greaterThanOrEqualTo(15), + reason: + 'highLatencyThresholdSeconds must stay above normal live latency ' + '(under ~15s) so healthy playback is not flagged for recovery.', + ); + }); + }); + + group('VideoTimingConstants short-play loop detection', () { + test('shortPlayWindow is positive', () { + expect(VideoTimingConstants.shortPlayWindow, greaterThan(Duration.zero)); + }); + + test('shortPlayLoopThreshold is a small positive int', () { + expect( + VideoTimingConstants.shortPlayLoopThreshold, + greaterThan(0), + reason: 'Need at least one bounce to detect a failed-recovery loop.', + ); + expect( + VideoTimingConstants.shortPlayLoopThreshold, + lessThanOrEqualTo(10), + reason: + 'Threshold should stay small so a tailored error surfaces promptly ' + 'rather than after many silent loops.', + ); + }); + }); + + group('VideoTimingConstants overlay ordering', () { + test('overlayQuickHide is not slower than overlayAutoHide', () { + expect( + VideoTimingConstants.overlayQuickHide, + lessThanOrEqualTo(VideoTimingConstants.overlayAutoHide), + reason: + 'The quick-hide path must hide the overlay at least as fast as the ' + 'auto-hide path; otherwise "quick" would be slower than the default.', + ); + }); + }); + + group('VideoTimingConstants positive intervals', () { + test('nativeStreamInfoInterval is positive', () { + expect( + VideoTimingConstants.nativeStreamInfoInterval, + greaterThan(Duration.zero), + ); + }); + + test('latencyPollingInterval is positive', () { + expect( + VideoTimingConstants.latencyPollingInterval, + greaterThan(Duration.zero), + ); + }); + + test('maxRefreshAttempts is positive', () { + expect(VideoTimingConstants.maxRefreshAttempts, greaterThan(0)); + }); + }); +} diff --git a/web/.gitignore b/web/.gitignore index a399f275..f51043ee 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts # ai .claude/settings.local.json +.playwright-mcp diff --git a/web/AGENTS.md b/web/AGENTS.md index a53f397c..7926777d 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,5 +1,19 @@ # AGENTS.md +--- + +# Author Preferences + +## Infrastructure Checklist + +When creating new infrastructure (routes, API handlers, providers), use exploration findings as a **checklist** — systematically verify each convention is followed before writing code. + +--- + +# Project + +Next.js 16 landing page for [Frosty](https://frostyapp.io) using the App Router with React 19. Deployed on **Cloudflare Workers** via `@opennextjs/cloudflare`. + ## Commands ```bash @@ -17,25 +31,25 @@ pnpm clean # Delete .next, .open-next, and node_modules pnpm nuke # Delete .next, .open-next, node_modules, and pnpm-lock.yaml ``` -## Architecture - -Next.js 16 landing page for [Frosty](https://frostyapp.io) using the App Router with React 19. Deployed on **Cloudflare Workers** via `@opennextjs/cloudflare`. - -### Key Configuration - -- **React Compiler**: Enabled in `next.config.ts` for automatic memoization -- **Typed Routes**: Enabled for type-safe navigation (use `Route` type from `next/navigation`) -- **Path Alias**: `@/*` maps to `./src/*` -- **Strict TypeScript**: `noUncheckedIndexedAccess`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noImplicitOverride`, `verbatimModuleSyntax` - -### Source Structure +## Source Structure - `src/app/` - App Router pages and layouts - `src/components/` - React components (`ui/` subdirectory for shadcn `base-vega` style — add with `pnpm dlx shadcn@latest add `) - `src/lib/` - Utilities (`cn()` for className merging, `constants.ts` for external links) - `src/assets/` - Static images (screenshots) -### Cloudflare Workers +## Key Configuration + +- **React Compiler**: Enabled in `next.config.ts` for automatic memoization +- **Typed Routes**: Enabled for type-safe navigation (use `Route` type from `next/navigation`) +- **Path Alias**: `@/*` maps to `./src/*` +- **Strict TypeScript**: `noUncheckedIndexedAccess`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noImplicitOverride`, `verbatimModuleSyntax` + +## Cache Components + +Not yet enabled — `cacheComponents` is not set in `next.config.ts` and no `"use cache"` directives are in use. Everything is dynamic (SSR) by default. To opt in: add `cacheComponents: true` to `next.config.ts`, use `"use cache"` + `cacheLife()` on server components/functions, and wrap async work in `` for PPR. Durable runtime cache requires R2 incremental cache (see `open-next.config.ts`) — without it, cache is in-memory only per Worker instance. + +## Cloudflare Workers - `wrangler.jsonc` - Cloudflare Workers configuration (bindings, R2, KV, etc.) - `open-next.config.ts` - OpenNext adapter config (ISR requires uncommenting R2 incremental cache) @@ -67,9 +81,12 @@ Notable strict rules enforced: - `react-you-might-not-need-an-effect` - Avoid unnecessary useEffect - Unused variables must be prefixed with `_` -## Commits +## Gotchas -Lowercase, no prefixes (e.g., `remove marquee emote background from hero section`). Keep commits tightly scoped. +- **shadcn uses @base-ui/react**: Not Radix UI — component primitives differ from older shadcn examples. `button.tsx` is a `'use client'` module with no `asChild` prop. Check `src/components/ui/` before building custom UI. +- **`useSearchParams()` needs Suspense**: Always wrap components using `useSearchParams()` in a `` boundary — required for production builds. +- **Never remove `tw-animate-css`**: Required by shadcn/ui components for animations. Check shadcn dependencies before removing any package. +- **No `pnpm` prefix inside package.json scripts**: The package manager is already the script runner. Use bare commands (e.g., `next build`, not `pnpm next build`). ## Dev Tooling diff --git a/web/package.json b/web/package.json index 97f062ab..64276b4f 100644 --- a/web/package.json +++ b/web/package.json @@ -36,7 +36,6 @@ "@eslint/js": "^9.39.2", "@opennextjs/cloudflare": "^1.16.5", "@tailwindcss/postcss": "^4.1.18", - "@tailwindcss/typography": "^0.5.19", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -50,7 +49,6 @@ "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.7.2", - "react-scan": "^0.5.3", "shadcn": "^3.8.5", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1fe8ba8e..9e80f6cb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -60,9 +60,6 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 - '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.1.18) '@types/node': specifier: ^25.2.3 version: 25.2.3 @@ -102,9 +99,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.7.2 version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) - react-scan: - specifier: ^0.5.3 - version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) shadcn: specifier: ^3.8.5 version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3) @@ -612,12 +606,6 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} engines: {node: '>=18'} @@ -630,12 +618,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.4': resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} engines: {node: '>=18'} @@ -648,12 +630,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.4': resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} engines: {node: '>=18'} @@ -666,12 +642,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.4': resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} engines: {node: '>=18'} @@ -684,12 +654,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.4': resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} engines: {node: '>=18'} @@ -702,12 +666,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.4': resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} engines: {node: '>=18'} @@ -720,12 +678,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.4': resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} engines: {node: '>=18'} @@ -738,12 +690,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.4': resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} engines: {node: '>=18'} @@ -756,12 +702,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.4': resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} engines: {node: '>=18'} @@ -774,12 +714,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.4': resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} engines: {node: '>=18'} @@ -792,12 +726,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.4': resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} engines: {node: '>=18'} @@ -810,12 +738,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.4': resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} engines: {node: '>=18'} @@ -828,12 +750,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.4': resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} engines: {node: '>=18'} @@ -846,12 +762,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.4': resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} engines: {node: '>=18'} @@ -864,12 +774,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.4': resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} engines: {node: '>=18'} @@ -882,12 +786,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.4': resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} engines: {node: '>=18'} @@ -900,12 +798,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.4': resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} engines: {node: '>=18'} @@ -918,12 +810,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.25.4': resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} engines: {node: '>=18'} @@ -936,12 +822,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.4': resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} engines: {node: '>=18'} @@ -954,12 +834,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.25.4': resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} engines: {node: '>=18'} @@ -972,12 +846,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.4': resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} engines: {node: '>=18'} @@ -990,24 +858,12 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.4': resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} engines: {node: '>=18'} @@ -1020,12 +876,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.4': resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} engines: {node: '>=18'} @@ -1038,12 +888,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.4': resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} engines: {node: '>=18'} @@ -1056,12 +900,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.4': resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} engines: {node: '>=18'} @@ -1546,23 +1384,6 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@preact/signals-core@1.13.0': - resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==} - - '@preact/signals@1.3.4': - resolution: {integrity: sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==} - peerDependencies: - preact: 10.x - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1887,11 +1708,6 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -1916,9 +1732,6 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} - '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -1927,11 +1740,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-reconciler@0.28.9': - resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} - peerDependencies: - '@types/react': '*' - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2288,11 +2096,6 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - bippy@0.5.30: - resolution: {integrity: sha512-8CFmJAHD3gmTLDOCDHuWhjm1nxHSFZdlGoWtak9r53Uxn36ynOjxBLyxXHh/7h/XiKLyPvfdXa0gXWcD9o9lLQ==} - peerDependencies: - react: '>=17.0.1' - birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -2658,11 +2461,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -2859,12 +2657,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3979,10 +3771,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -3999,9 +3787,6 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - preact@10.28.3: - resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4123,13 +3908,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-scan@0.5.3: - resolution: {integrity: sha512-qde9PupmUf0L3MU1H6bjmoukZNbCXdMyTEwP4Gh8RQ4rZPd2GGNBgEKWszwLm96E8k+sGtMpc0B9P0KyFDP6Bw==} - hasBin: true - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -4547,9 +4325,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4576,10 +4351,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin@2.1.0: - resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} - engines: {node: '>=18.12.0'} - unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -4625,9 +4396,6 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5816,231 +5584,153 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.25.4': optional: true '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - '@esbuild/android-arm64@0.25.4': optional: true '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - '@esbuild/android-arm@0.25.4': optional: true '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - '@esbuild/android-x64@0.25.4': optional: true '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - '@esbuild/darwin-arm64@0.25.4': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - '@esbuild/darwin-x64@0.25.4': optional: true '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - '@esbuild/freebsd-arm64@0.25.4': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - '@esbuild/freebsd-x64@0.25.4': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - '@esbuild/linux-arm64@0.25.4': optional: true '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - '@esbuild/linux-arm@0.25.4': optional: true '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - '@esbuild/linux-ia32@0.25.4': optional: true '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - '@esbuild/linux-loong64@0.25.4': optional: true '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - '@esbuild/linux-mips64el@0.25.4': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - '@esbuild/linux-ppc64@0.25.4': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - '@esbuild/linux-riscv64@0.25.4': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - '@esbuild/linux-s390x@0.25.4': optional: true '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - '@esbuild/linux-x64@0.25.4': optional: true '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - '@esbuild/netbsd-arm64@0.25.4': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - '@esbuild/netbsd-x64@0.25.4': optional: true '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - '@esbuild/openbsd-arm64@0.25.4': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - '@esbuild/openbsd-x64@0.25.4': optional: true '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - '@esbuild/sunos-x64@0.25.4': optional: true '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - '@esbuild/win32-arm64@0.25.4': optional: true '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - '@esbuild/win32-ia32@0.25.4': optional: true '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - '@esbuild/win32-x64@0.25.4': optional: true @@ -6546,19 +6236,6 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@preact/signals-core@1.13.0': {} - - '@preact/signals@1.3.4(preact@10.28.3)': - dependencies: - '@preact/signals-core': 1.13.0 - preact: 10.28.3 - - '@rollup/pluginutils@5.3.0': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - '@rtsao/scc@1.1.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -6980,11 +6657,6 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)': - dependencies: - postcss-selector-parser: 6.0.10 - tailwindcss: 4.1.18 - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -7013,10 +6685,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.33': - dependencies: - undici-types: 6.21.0 - '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -7025,10 +6693,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react-reconciler@0.28.9(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -7417,13 +7081,6 @@ snapshots: baseline-browser-mapping@2.9.19: {} - bippy@0.5.30(@types/react@19.2.14)(react@19.2.4): - dependencies: - '@types/react-reconciler': 0.28.9(@types/react@19.2.14) - react: 19.2.4 - transitivePeerDependencies: - - '@types/react' - birecord@0.1.1: {} blake3-wasm@2.1.5: {} @@ -7824,35 +7481,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -8233,12 +7861,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@2.0.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - esutils@2.0.3: {} etag@1.8.1: {} @@ -9323,11 +8945,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@6.0.10: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -9347,8 +8964,6 @@ snapshots: powershell-utils@0.1.0: {} - preact@10.28.3: {} - prelude-ls@1.2.1: {} prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): @@ -9412,30 +9027,6 @@ snapshots: react-is@16.13.1: {} - react-scan@0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/types': 7.29.0 - '@preact/signals': 1.3.4(preact@10.28.3) - '@rollup/pluginutils': 5.3.0 - '@types/node': 20.19.33 - bippy: 0.5.30(@types/react@19.2.14)(react@19.2.4) - commander: 14.0.3 - esbuild: 0.25.12 - estree-walker: 3.0.3 - picocolors: 1.1.1 - preact: 10.28.3 - prompts: 2.4.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - unplugin: 2.1.0 - transitivePeerDependencies: - - '@types/react' - - rollup - - supports-color - react@19.2.4: {} recast@0.23.11: @@ -9982,8 +9573,6 @@ snapshots: undici-types@5.26.5: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} undici@7.16.0: {} @@ -10000,12 +9589,6 @@ snapshots: unpipe@1.0.0: {} - unplugin@2.1.0: - dependencies: - acorn: 8.15.0 - webpack-virtual-modules: 0.6.2 - optional: true - unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -10060,9 +9643,6 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-virtual-modules@0.6.2: - optional: true - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/web/public/_headers b/web/public/_headers index 5392acf8..c846ea71 100644 --- a/web/public/_headers +++ b/web/public/_headers @@ -1,8 +1,2 @@ /_next/static/* Cache-Control: public, max-age=31536000, immutable - -/video.webm - Cache-Control: public, max-age=2592000 - -/logo.svg - Cache-Control: public, max-age=2592000 diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 63eec7d0..e01ee0cb 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,8 +2,6 @@ @import 'tw-animate-css'; @import 'shadcn/tailwind.css'; -@plugin '@tailwindcss/typography'; - @custom-variant dark (&:where(.dark, .dark *)); @theme inline { diff --git a/web/src/components/Providers.tsx b/web/src/components/Providers.tsx index 8f9612c6..617d32c7 100644 --- a/web/src/components/Providers.tsx +++ b/web/src/components/Providers.tsx @@ -3,21 +3,12 @@ import { Toaster } from '@/components/ui/sonner' import { MotionConfig } from 'motion/react' import { ThemeProvider } from 'next-themes' -import { useEffect } from 'react' interface ProvidersProps { children: React.ReactNode } export function Providers({ children }: ProvidersProps) { - useEffect(() => { - if (process.env.NODE_ENV === 'development') { - void import('react-scan').then(({ scan }) => { - scan({ enabled: true }) - }) - } - }, []) - return ( diff --git a/web/wrangler.jsonc b/web/wrangler.jsonc index 4dd24a14..348d12cb 100644 --- a/web/wrangler.jsonc +++ b/web/wrangler.jsonc @@ -29,11 +29,6 @@ // "bucket_name": "", // }, // ], - "images": { - // Enable image optimization - // see https://opennext.js.org/cloudflare/howtos/image - "binding": "IMAGES", - }, "minify": true, "keep_names": false, "upload_source_maps": true, From d19ac38d6b23de1a8176c1050460384a02bbe534 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 13 Jun 2026 15:52:15 -0700 Subject: [PATCH 12/67] ci(workflows): align xcode setup and refresh locks --- .github/workflows/ci.yml | 4 + .github/workflows/claude-code-review.yml | 3 +- .github/workflows/claude.yml | 3 +- .github/workflows/gh-release.yml | 2 +- .github/workflows/release-dryrun.yml | 2 +- .github/workflows/store-release.yml | 2 +- android/Gemfile.lock | 115 +- ios/Gemfile.lock | 115 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- pubspec.lock | 16 +- web/pnpm-lock.yaml | 4965 ++++++++--------- 12 files changed, 2437 insertions(+), 2806 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93c2d12a..ce303d57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v6 + # Match release workflows and avoid GitHub's lagging default Xcode. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - uses: subosito/flutter-action@v2 with: flutter-version: 3.44.1 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..66e16681 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 @@ -41,4 +41,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f..50f44994 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index da793ce9..c668613e 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -97,7 +97,7 @@ jobs: # Create a draft release on GitHub. - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: true generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }} diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index 7941f249..2c97dd62 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -89,7 +89,7 @@ jobs: - name: Checkout Frosty repo uses: actions/checkout@v6 - # Always pick the newest production Xcode on the runner + # Always pick the newest production Xcode on the runner. - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index 84b97315..353deb9b 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -85,7 +85,7 @@ jobs: - name: Checkout Frosty repo uses: actions/checkout@v6 - # Always pick the newest production Xcode on the runner + # Always pick the newest production Xcode on the runner. - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable diff --git a/android/Gemfile.lock b/android/Gemfile.lock index d27ecafb..41b69f5c 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1200.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1260.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,18 +17,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.225.1) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) - bigdecimal (4.0.1) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,7 +43,7 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.4) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -67,19 +68,20 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.230.0) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) + fastimage (2.4.1) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) @@ -90,11 +92,11 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) 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-env (>= 1.6.0, <= 2.1.1) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) @@ -103,10 +105,11 @@ GEM logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -119,41 +122,42 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - 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.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.8.0) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) @@ -164,42 +168,41 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) - jwt (2.10.2) + json (2.19.9) + jwt (2.10.3) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.21.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nkf (0.2.0) + nkf (0.3.0) optparse (0.8.1) os (1.1.4) + ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.0) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.21.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 06923c0a..7d14f995 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1200.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1260.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,18 +17,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.225.1) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) - bigdecimal (4.0.1) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,7 +43,7 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.4) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -67,19 +68,20 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.230.0) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) + fastimage (2.4.1) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) @@ -90,11 +92,11 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) 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-env (>= 1.6.0, <= 2.1.1) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) @@ -103,10 +105,11 @@ GEM logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -119,41 +122,42 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - 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.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.8.0) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) @@ -164,42 +168,41 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) - jwt (2.10.2) + json (2.19.9) + jwt (2.10.3) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.21.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nkf (0.2.0) + nkf (0.3.0) optparse (0.8.1) os (1.1.4) + ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.0) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.21.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7baba9ed..38e2de1f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" } }, { diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7baba9ed..38e2de1f 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" } }, { diff --git a/pubspec.lock b/pubspec.lock index a2e24717..4f4b25b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: a350a5b37579b7227aaf9a59c07114617cd4283852e193f743b2b3d2d7483c18 + sha256: "4ec328cd9fd51fd0e7eb8870a9612c1fde0f091901932238c4f4e60b5f7f1315" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.1.2" app_links_linux: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" app_links_web: dependency: transitive description: @@ -1204,10 +1204,10 @@ packages: dependency: transitive description: name: sqflite_common - sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014 + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.dev" source: hosted - version: "2.5.9" + version: "2.5.11" sqflite_darwin: dependency: transitive description: @@ -1460,10 +1460,10 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4 url: "https://pub.dev" source: hosted - version: "4.13.1" + version: "4.14.0" webview_flutter_android: dependency: "direct main" description: diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9e80f6cb..9096a24e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.2.0 - version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -19,101 +19,101 @@ importers: version: 2.1.1 lucide-react: specifier: ^0.574.0 - version: 0.574.0(react@19.2.4) + version: 0.574.0(react@19.2.7) motion: specifier: ^12.34.1 - version: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: ^16.1.6 - version: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^19.2.4 - version: 19.2.4 + version: 19.2.7 react-dom: specifier: ^19.2.4 - version: 19.2.4(react@19.2.4) + version: 19.2.7(react@19.2.7) react-icons: specifier: ^5.5.0 - version: 5.5.0(react@19.2.4) + version: 5.6.0(react@19.2.7) sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.4.1 - version: 3.4.1 + version: 3.6.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 devDependencies: '@eslint-react/eslint-plugin': specifier: ^2.12.4 - version: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint/js': specifier: ^9.39.2 - version: 9.39.2 + version: 9.39.4 '@opennextjs/cloudflare': specifier: ^1.16.5 - version: 1.16.5(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.66.0) + version: 1.19.11(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(wrangler@4.100.0) '@tailwindcss/postcss': specifier: ^4.1.18 - version: 4.1.18 + version: 4.3.1 '@types/node': specifier: ^25.2.3 - version: 25.2.3 + version: 25.9.3 '@types/react': specifier: ^19.2.14 - version: 19.2.14 + version: 19.2.17 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.17) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 eslint: specifier: ^9.39.2 - version: 9.39.2(jiti@2.6.1) + version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: ^16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-you-might-not-need-an-effect: specifier: ^0.9.1 - version: 0.9.1(eslint@9.39.2(jiti@2.6.1)) + version: 0.9.3(eslint@9.39.4(jiti@2.7.0)) next-devtools-mcp: specifier: ^0.3.10 version: 0.3.10 postcss: specifier: ^8.5.6 - version: 8.5.6 + version: 8.5.15 prettier: specifier: ^3.8.1 - version: 3.8.1 + version: 3.8.4 prettier-plugin-organize-imports: specifier: ^4.3.0 - version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.4)(typescript@5.9.3) prettier-plugin-tailwindcss: specifier: ^0.7.2 - version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) + version: 0.7.4(prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3))(prettier@3.8.4) shadcn: specifier: ^3.8.5 - version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3) + version: 3.8.5(@types/node@25.9.3)(typescript@5.9.3) tailwindcss: specifier: ^4.1.18 - version: 4.1.18 + version: 4.3.1 typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: specifier: ^8.56.0 - version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) wrangler: specifier: ^4.66.0 - version: 4.66.0 + version: 4.100.0 packages: @@ -206,6 +206,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.5': + resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-cloudfront@3.984.0': resolution: {integrity: sha512-couDuDLpJtoeWne/nYyJ+I+5ntBVdNgBVRTCoDaXuVV7OC3u/wz5Ps0+GogspEwMLEFoOJ8t691h3YXQtnpQTw==} engines: {node: '>=20.0.0'} @@ -226,308 +230,297 @@ packages: resolution: {integrity: sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.990.0': - resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==} + '@aws-sdk/core@3.974.20': + resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.10': - resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==} + '@aws-sdk/credential-provider-env@3.972.46': + resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.0': - resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + '@aws-sdk/credential-provider-http@3.972.48': + resolution: {integrity: sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.8': - resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==} + '@aws-sdk/credential-provider-ini@3.972.53': + resolution: {integrity: sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.10': - resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==} + '@aws-sdk/credential-provider-login@3.972.52': + resolution: {integrity: sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.8': - resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==} + '@aws-sdk/credential-provider-node@3.972.55': + resolution: {integrity: sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.8': - resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==} + '@aws-sdk/credential-provider-process@3.972.46': + resolution: {integrity: sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.9': - resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==} + '@aws-sdk/credential-provider-sso@3.972.52': + resolution: {integrity: sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.8': - resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==} + '@aws-sdk/credential-provider-web-identity@3.972.52': + resolution: {integrity: sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.8': - resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==} + '@aws-sdk/dynamodb-codec@3.973.20': + resolution: {integrity: sha512-SFfxiqVgWeIe+RsJJNAMD//2IfehT4bLpGyNJRB0MgHmOIJtdcfMnR1k7KYyaHokSoQVdncVa9O9DIGa4eqcwg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.8': - resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==} + '@aws-sdk/endpoint-cache@3.972.7': + resolution: {integrity: sha512-LkwS3ZOUNL5kHzmz3dDx8lE3HOhZmf2VGjbJ/tMUZJYWWl3J0RJTZM7RFz1MLt06WDVvlShcAjY/RzhYlqLL7g==} engines: {node: '>=20.0.0'} - '@aws-sdk/dynamodb-codec@3.972.11': - resolution: {integrity: sha512-A0oji7TdKmua93ehEmozehNbUzgq912LRdOMx4bviVRo02DFaf9+fnwT4UUvvyIvnMBcFjT39kWP9fvka4dGUg==} + '@aws-sdk/middleware-bucket-endpoint@3.972.24': + resolution: {integrity: sha512-DUGVPyHUdSJ8reJrFTdSkt768EyEbX8quIcd6lqTwr2GqFX2yVocSv66akxFd21VJvdFNdhLU4D+NHCvR6gmOw==} engines: {node: '>=20.0.0'} - '@aws-sdk/endpoint-cache@3.972.2': - resolution: {integrity: sha512-3L7mwqSLJ6ouZZKtCntoNF0HTYDNs1FDQqkGjoPWXcv1p0gnLotaDmLq1rIDqfu4ucOit0Re3ioLyYDUTpSroA==} + '@aws-sdk/middleware-endpoint-discovery@3.972.18': + resolution: {integrity: sha512-1vKJt/6MBB/MBMRM3qzCMdW70syJY8u2DH+dq7yCnPn7wVJmyeAzAa/sK1lIbbYh8BVLbM5FspsT4zbe885gOw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.3': - resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} + '@aws-sdk/middleware-expect-continue@3.972.20': + resolution: {integrity: sha512-S6sqRv/HhFfv+eCwi+dSIjRTXAxLxyESJIP9giIIVuJAEdi2zFoMd0PdIg65U7JprT7yq0TvHilp2BBDbw8slw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-endpoint-discovery@3.972.3': - resolution: {integrity: sha512-xAxA8/TOygQmMrzcw9CrlpTHCGWSG/lvzrHCySfSZpDN4/yVSfXO+gUwW9WxeskBmuv9IIFATOVpzc9EzfTZ0Q==} + '@aws-sdk/middleware-flexible-checksums@3.974.30': + resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.3': - resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} + '@aws-sdk/middleware-host-header@3.972.21': + resolution: {integrity: sha512-+SRZL54/T9Ryxa/NmHM7WZAliU6wnfzeosT/+4IVuTgq0zSCpPx2j3yaEP4JFZlvWvoOCbKgr+4tBqSAG/dl5Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.972.8': - resolution: {integrity: sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==} + '@aws-sdk/middleware-location-constraint@3.972.17': + resolution: {integrity: sha512-DYCppgVKrKRuS+bVJsgQ0N6uARd9YoF1H1rVeTnhHsCp2Ppru9BXb4LdvDQzoKc4fK+MABl3Fz9ck40WBrUsWA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + '@aws-sdk/middleware-logger@3.972.20': + resolution: {integrity: sha512-8oOvo7XNDeOlwa5ys2Q0UTLCKmtvRiqf8rOU63lKniPmP3dnI5lnoy9dteZ79lxb9hmXCrO28aZMqds6C5AEoA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.3': - resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + '@aws-sdk/middleware-recursion-detection@3.972.22': + resolution: {integrity: sha512-q4H/CoOYrTbyAW0d9RrHf9kTYKVXpAwoK0VEy3UT2Asad+6aa6vzQgz35dh20tRA7zlEo/Nsyjy9PVlHgdq0Vg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + '@aws-sdk/middleware-sdk-s3@3.972.51': + resolution: {integrity: sha512-keQgcIUTcHL0Qn7guhsuLaxQU36r9norCrxgaPH4DNCwon4TPtXdI/UdYuycl9vj3Dlwc3YR1dfL3U+6iIwJ6w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + '@aws-sdk/middleware-sdk-sqs@3.972.30': + resolution: {integrity: sha512-PVAj7VgWK/ZxCXnkgC4B7cdJyUN99Nsr7IEduHt4A1GieuB+ZnU5bSifHwapbr17wrFkmdxfSh+aA0Lj+Ads6w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.10': - resolution: {integrity: sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==} + '@aws-sdk/middleware-ssec@3.972.17': + resolution: {integrity: sha512-LGpxxtMv/8cQnLeUiZqubqlzSA5ebv+yx+651dq7Sf+KxErRFaj6bdz7Rl9sO8ixvm5+ROfHeZDdNUprVz1etg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-sqs@3.972.7': - resolution: {integrity: sha512-DcJLYE4sRjgUyb2SupQGaRgBYc+j89N9nXeMT0PwwVvaBGmKqcxa7PFvz0kBnQrBckPWlfrPyyyMwOeT5BEp6Q==} + '@aws-sdk/middleware-user-agent@3.972.50': + resolution: {integrity: sha512-5JIDcDFWy3DUW95sOlXLbeb7RkGFUcNh1QidKsznqtlm5YGsXP0EGWaqzxBTvVmOhqKs2RmNmI6w9V/5dS3CLQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.3': - resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} + '@aws-sdk/nested-clients@3.997.20': + resolution: {integrity: sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.10': - resolution: {integrity: sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.990.0': - resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + '@aws-sdk/region-config-resolver@3.972.24': + resolution: {integrity: sha512-WY6uVMsq0EvxY4BcYhZmG2Ivd1EzNvZAqsXFlL3pTPMG0P4J83TYVQIs8P0nd5lc+Bp3llrYwggruvXzrfUtsQ==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.984.0': resolution: {integrity: sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.990.0': - resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==} + '@aws-sdk/signature-v4-multi-region@3.996.34': + resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + '@aws-sdk/token-providers@3.1066.0': + resolution: {integrity: sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.2': - resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + '@aws-sdk/types@3.973.12': + resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} engines: {node: '>=20.0.0'} '@aws-sdk/util-endpoints@3.984.0': resolution: {integrity: sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.990.0': - resolution: {integrity: sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==} + '@aws-sdk/util-locate-window@3.965.7': + resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.4': - resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + '@aws-sdk/util-user-agent-browser@3.972.21': + resolution: {integrity: sha512-Y9MPH4VZaIebwxiVK6dMzSt5L04oyNiK3NNwFe4qP5B2Hfo+pmEVpSJSa+gARPtcJeRyehUMPu5/I9DLdW0cBg==} + + '@aws-sdk/util-user-agent-node@3.973.36': + resolution: {integrity: sha512-wsmSmHTrK9lV+5SKBekp1J7W6PufdZE08X0xv5Lz1OdgYRmyuYDy/++SslKdXhcVQjxLtzMTZaLqqLZmTx6OaQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - - '@aws-sdk/util-user-agent-node@3.972.8': - resolution: {integrity: sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.972.4': - resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + '@aws-sdk/xml-builder@3.972.29': + resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.2.0': - resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==} + '@base-ui/react@1.5.0': + resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} engines: {node: '>=14.0.0'} peerDependencies: + '@date-fns/tz': ^1.2.0 '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 react: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19 peerDependenciesMeta: + '@date-fns/tz': + optional: true '@types/react': optional: true + date-fns: + optional: true - '@base-ui/utils@0.2.5': - resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==} + '@base-ui/utils@0.2.9': + resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -536,45 +529,45 @@ packages: '@types/react': optional: true - '@cloudflare/kv-asset-handler@0.4.2': - resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} - engines: {node: '>=18.0.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} - '@cloudflare/unenv-preset@2.13.0': - resolution: {integrity: sha512-bT2rnecesLjDBHgouMEPW9EQ7iLE8OG58srMuCEpAGp75xabi6j124SdS8XZ+dzB3sYBW4iQvVeCTCbAnMMVtA==} + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} peerDependencies: unenv: 2.0.0-rc.24 - workerd: ^1.20260213.0 + workerd: '>1.20260305.0 <2.0.0-0' peerDependenciesMeta: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260217.0': - resolution: {integrity: sha512-t1KRT0j4gwLntixMoNujv/UaS89Q7+MPRhkklaSup5tNhl3zBZOIlasBUSir69eXetqLZu8sypx3i7zE395XXA==} + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260217.0': - resolution: {integrity: sha512-9pEZ15BmELt0Opy79LTxUvbo55QAI4GnsnsvmgBxaQlc4P0dC8iycBGxbOpegkXnRx/LFj51l2zunfTo0EdATg==} + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260217.0': - resolution: {integrity: sha512-IrZfxQ4b/4/RDQCJsyoxKrCR+cEqKl81yZOirMOKoRrDOmTjn4evYXaHoLBh2PjUKY1Imly7ZiC6G1p0xNIOwg==} + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260217.0': - resolution: {integrity: sha512-RGU1wq69ym4sFBVWhQeddZrRrG0hJM/SlZ5DwVDga/zBJ3WXxcDsFAgg1dToDfildTde5ySXN7jAasSmWko9rg==} + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260217.0': - resolution: {integrity: sha512-4T65u1321z1Zet9n7liQsSW7g3EXM5SWIT7kJ/uqkEtkPnIzZBIowMQgkvL5W9SpGZks9t3mTQj7hiUia8Gq9Q==} + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -587,24 +580,27 @@ packages: resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==} hasBin: true - '@dotenvx/dotenvx@1.52.0': - resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + '@dotenvx/dotenvx@1.71.3': + resolution: {integrity: sha512-WSmox5aD+XxJEUEOTk7gKLpd5+Iz9Nik89Zpbu5DijMln6LsFiv3xpNKBMc/b9sSkUlKvAblzrhik2TqKFE7NA==} hasBin: true - '@ecies/ciphers@0.2.5': - resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} - engines: {bun: '>=1', deno: '>=2', node: '>=16'} + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} @@ -961,8 +957,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -973,12 +969,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -989,33 +985,37 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -1026,8 +1026,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -1163,49 +1163,41 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1242,8 +1234,8 @@ packages: '@cfworker/json-schema': optional: true - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1252,63 +1244,66 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.9': + resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} - '@next/eslint-plugin-next@16.1.6': - resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + '@next/eslint-plugin-next@16.2.9': + resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.2.9': + resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.9': + resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.9': + resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.9': + resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.9': + resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.9': + resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.9': + resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.9': + resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1325,6 +1320,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@node-minify/core@8.0.6': resolution: {integrity: sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g==} engines: {node: '>=16.0.0'} @@ -1356,24 +1354,27 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opennextjs/aws@3.9.16': - resolution: {integrity: sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q==} + '@opennextjs/aws@4.0.2': + resolution: {integrity: sha512-nXQPT8GZDV+NWMJV9mD/Ywxyo+tCZfFvrR6jpEOYNcxK/AqiEDlJzPybGOrHUDWAYdR1b6tmh+MoR3VbqIao3w==} hasBin: true peerDependencies: - next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 + next: '>=15.5.18 <16 || >=16.2.6' - '@opennextjs/cloudflare@1.16.5': - resolution: {integrity: sha512-Ir93CZLrYJJKIVmPcozHILTFjqWuAT8Ur8lLvpWj6+fsBIqMKAOYQr2VoxPCWtp4UYWbiR3iFNwc+d0Eq4I2GQ==} + '@opennextjs/cloudflare@1.19.11': + resolution: {integrity: sha512-spZ7YsZZW9q/OJStaZlJhuklYKei5e2tNQ7PMi3DDSJ7x7167m7EYYHQZfVN5gwJBDkMR2jUDW88oHjnGz4YYw==} hasBin: true peerDependencies: - next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 - wrangler: ^4.59.2 + next: '>=15.5.18 <16 || >=16.2.6' + wrangler: ^4.86.0 '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1398,287 +1399,223 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + '@smithy/config-resolver@4.5.7': + resolution: {integrity: sha512-1mmr3q0viZVm9j4yYqDJFo4Yj+CmLPNYHmxweKrL08xPC1UlUKVawGYKokUlryd9Llgev91D9cWRcDmWeHKvLg==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader-native@4.2.1': - resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + '@smithy/core@3.24.7': + resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader@5.2.0': - resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + '@smithy/credential-provider-imds@4.3.9': + resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + '@smithy/eventstream-serde-browser@4.3.7': + resolution: {integrity: sha512-Ux78G1xlZHYLn8jd8KtMSUo0Hr33cw7lmKdVHWwmPe0TdsR6l9Zx2T9Ljx7kcurSVU9KUwKC4wR4KebbClBWdA==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.0': - resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} + '@smithy/eventstream-serde-config-resolver@4.4.7': + resolution: {integrity: sha512-OH27/c5hUdDXVdFRBTr/dlJFfFSjccVoxSJ1p0QdDK6xP/v0cE7N2GtAuq1h/BQ5TFCXoeEmDnvcS7fwW8/+1g==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + '@smithy/eventstream-serde-node@4.3.7': + resolution: {integrity: sha512-zo8M9AzKmMsLUS9zEGo2UIW3odKCpHQXD7WSvRFBTbD8d+kn5iBphsinNyVe3XtdSoSs1XF8qDxbznW+PsZeBg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + '@smithy/fetch-http-handler@5.4.7': + resolution: {integrity: sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + '@smithy/hash-blob-browser@4.3.7': + resolution: {integrity: sha512-JcP/TiSUBrHZjrY1z/jwiSxBJgkJMwYdqkyXHilg/Riybnm7hH0YQfBPk4y+09efz+BPZEBemaxAOETxPRpdLg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + '@smithy/hash-node@4.3.7': + resolution: {integrity: sha512-92AAv0TrUw6nVbKZPiGe+DmfY8CLo6fLh0Xz6dUC75bKviYZSIUMAquC4VTR9fjXlxh+NX+3SqyHhmGVcAqkAg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + '@smithy/hash-stream-node@4.3.7': + resolution: {integrity: sha512-h/sJCJiB7HmxnuiEgbqZGsULO4yHrK6zqSx2Cyu14aONhTi6klMoqcR1WkkjMlul3wVADj5eAu2mptcnV0BsXA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-blob-browser@4.2.9': - resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-stream-node@4.2.8': - resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + '@smithy/invalid-dependency@4.3.7': + resolution: {integrity: sha512-oUWWs71R3+VjlZJU6IBcLdN/vvH7jbJ11Dy7m73bQoVcbFuuVQGoVrL3IHcSpaIsuWgEQPziKBSVzTd4Uhr+5w==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + '@smithy/md5-js@4.3.7': + resolution: {integrity: sha512-l1QeGbpVKzVhTxqY8aUMfLyVZ3D7QLUaiEVF8zqOZTCOiMSIIRH+PIzzBGGMECitXUwt0ZF/FFETamd3pNH+lw==} engines: {node: '>=18.0.0'} - '@smithy/md5-js@4.2.8': - resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + '@smithy/middleware-content-length@4.3.7': + resolution: {integrity: sha512-LvpbwYfDyDg3ez8qM+0+aoo+tZvG1+cJR2DLfSJn0feDBMevQwU/B85OBjnGGwyxhOFJXiGriqt1Qn/9mS8T3A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + '@smithy/middleware-endpoint@4.5.7': + resolution: {integrity: sha512-rs+xOLEA/i5O/VWrtxjw8Wydk4gt0TcG1XBfWcSKpg41W+yWrkHuQUFrID5X+199P2DOnLoRcLmANU6IqqAKow==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.14': - resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} + '@smithy/middleware-retry@4.6.7': + resolution: {integrity: sha512-TH2Gw3wp+1iMMwvwpfX0otsnW0c91QttO2UmtftVpZvMH5c3QFOstTmMUXtnEii9CydT/xJno4leduaXMK2D4w==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.31': - resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} + '@smithy/middleware-serde@4.3.7': + resolution: {integrity: sha512-5z8w1mTB1J4jYIFpi69wOCTPMyxeggrDcz/S3KlPWOCs0PkazW33G2mAKu/b5CMN12NpZtUH4BDbYt1IfJ0FHA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + '@smithy/middleware-stack@4.3.7': + resolution: {integrity: sha512-xtA670qWcsZA1jBDMIvBz3aYm7DQteW9IzCjsFNLfSrGeEiCIRSfHug5MzJo66qyHdZKSidU+tjtTOIeldqI7Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + '@smithy/node-config-provider@4.4.7': + resolution: {integrity: sha512-GbLL8AyUmsYYmWBNa4YU4vlSDG2kBeLp6Sevh20dvnfRYsgl+eGR0dv9X5wKFDURiVZCruz6OgfM1AQYOHM04Q==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + '@smithy/node-http-handler@4.7.8': + resolution: {integrity: sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + '@smithy/protocol-http@5.4.7': + resolution: {integrity: sha512-C4SfAC15LwAvH6xzt/KklMPUDiTdxHH3fu9+qFeuve8j+pHQyT7pF9/Vot9HOheARmxf8bHCSxIB4kFHP/hKYg==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + '@smithy/signature-v4@5.4.7': + resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + '@smithy/smithy-client@4.13.7': + resolution: {integrity: sha512-kNOS4kEILDeE9Qpp7/rGSE/VMUOhVRan3g0zDj+hFB4Ka9JJ99hJHJ0mI5x5qhAAi/fZA5DNbVKB0+RWX9UGGw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + '@smithy/types@4.14.4': + resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + '@smithy/url-parser@4.3.7': + resolution: {integrity: sha512-nAKFs3vUD7IByqFq060Xaq2ct07HkarhVpPJTh2hb23Hczu+cZWXm2AXV0D+1xc/eCksJQCmT1/3SwEYw3LXJw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + '@smithy/util-base64@4.4.7': + resolution: {integrity: sha512-fCRquAqooXwff8PmhPSZatA4V5uBD23b+lX9lXIyNEisTekKVJ6EePEa2rmtopySyD2TpTlsEIcET0eNauQH1w==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + '@smithy/util-body-length-browser@4.3.7': + resolution: {integrity: sha512-NNlnEnix+DvaWvfVdJEGUewce5FbZvDfZYI6zl/UJGAJTIn5ZRKD7KXjQODhMq64UxLPajmnOmQM584Ys5ZT0g==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.11.3': - resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} - engines: {node: '>=18.0.0'} - - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + '@smithy/util-body-length-node@4.3.7': + resolution: {integrity: sha512-C/SdrjNeT2j5rSSHese6Vbg0Yyban3pVWtYaEZ/ZwgwSiFtCrPrLtL0L4yaAPc5+uMVVWSuDf1Fp1jvMq6+kbA==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + '@smithy/util-defaults-mode-browser@4.4.7': + resolution: {integrity: sha512-elDqX7i4Y1rcbebstHoMqSyZDktp7e4pfvqzUC/oCeaP8g/qDliIzwVAKs7tpS1m/C+pJ3THPdb3r9kwI63ycg==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + '@smithy/util-defaults-mode-node@4.3.7': + resolution: {integrity: sha512-Pw2vgWWtaX+c08r4TE1XZFsubVaFUyWv9l0TncGDpoXvuqef5+50gIdOgSF2Uf0Yjvc+r7Oz8AYtfUggXQ3ehg==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.30': - resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} + '@smithy/util-endpoints@3.5.7': + resolution: {integrity: sha512-HOSB9ARkpt2/LzwYMrXpoxlygo58FyIPS+CO0wAI4mAD0E15aQ5t6ZYa9WzSeWPKPed2Kmyl+NAMhsorA1aCLg==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.33': - resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} + '@smithy/util-middleware@4.3.7': + resolution: {integrity: sha512-q8P1up/IlZQNqopAHkGtKSpAUVdkacyVR8n8+7BjM89BBGbl6Wtf/2PSCMIYjFXKh2WegvN8ig4t4vX9WhUm8Q==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + '@smithy/util-retry@4.4.7': + resolution: {integrity: sha512-ijOv9opnYYNtDfi5yIjBUDfPirQiiggAQcKCthEkUofAhQRRu9IQSbhHP1RXGzrQJMgRjCZoLfx+JX5VucUXWQ==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + '@smithy/util-stream@4.6.7': + resolution: {integrity: sha512-FSdIw4ju2unfrxVmjbk+mwMJhwFkTZEYwbawd3DQMmG1Qh5MFvd6rUdtHgBP4Tya7BNcCkkZen45n9VxYxwizQ==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + '@smithy/util-utf8@4.3.7': + resolution: {integrity: sha512-U2XPgO73I8ef8FP3jRQO4Iiol7chWuv4TD6LxNLvRo/pYwBvbelxLcODkpAA9ek+k23NMgc9NoiVzp8INwogMA==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.2.8': - resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + '@smithy/util-waiter@4.4.7': + resolution: {integrity: sha512-U7tEAhbItBaJXmY4TfsXBR0YzWLuYPSUi1qNxsj44/5wqdzq/7jxor1oBwZMBEkDbJWGIc2J0uSmNtSZEWt6yQ==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} - engines: {node: '>=18.0.0'} - - '@speed-highlight/core@1.2.14': - resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1689,24 +1626,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + engines: {node: '>= 20'} - '@tailwindcss/postcss@4.1.18': - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tailwindcss/postcss@4.3.1': + resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -1714,11 +1651,11 @@ packages: '@tsconfig/node18@1.0.3': resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1732,16 +1669,19 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@25.2.3': - resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1749,201 +1689,172 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.61.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.0': - resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.56.0': - resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/tsconfig-utils@8.56.0': - resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.56.0': - resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.56.0': - resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.56.0': - resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.56.0': - resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.56.0': - resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} cpu: [x64] os: [win32] @@ -1960,8 +1871,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true @@ -1981,14 +1892,11 @@ packages: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -2010,10 +1918,13 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + anynum@1.0.0: + resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2029,6 +1940,9 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -2074,8 +1988,8 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2088,12 +2002,13 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.2: - resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} - engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.37: + resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + engines: {node: '>=6.0.0'} hasBin: true birecord@0.1.1: @@ -2109,22 +2024,22 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2143,8 +2058,8 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -2155,8 +2070,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -2166,6 +2081,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2228,20 +2147,28 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2261,8 +2188,8 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -2318,8 +2245,8 @@ packages: supports-color: optional: true - dedent@1.7.1: - resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -2365,8 +2292,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} doctrine@2.1.0: @@ -2377,8 +2304,8 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2388,15 +2315,15 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - eciesjs@0.4.17: - resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.372: + resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2411,8 +2338,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -2429,8 +2356,8 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -2441,12 +2368,12 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + es-iterator-helpers@1.3.3: + resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -2482,8 +2409,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + eslint-config-next@16.2.9: + resolution: {integrity: sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2497,8 +2424,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -2513,8 +2440,8 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + eslint-module-utils@2.13.0: + resolution: {integrity: sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2564,11 +2491,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react-naming-convention@2.13.0: resolution: {integrity: sha512-uSd25JzSg2R4p81s3Wqck0AdwRlO9Yc+cZqTEXv7vW8exGGAM3mWnF6hgrgdqVJqBEGJIbS/Vx1r5BdKcY/MHA==} @@ -2598,8 +2525,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - eslint-plugin-react-you-might-not-need-an-effect@0.9.1: - resolution: {integrity: sha512-P6Zq5hAKc10Zg5V6vusm3+WZc59OHqHeCTTH2soW3r9fBmeJssuHzltIEKtrONXqFwHVWw3bz2qGlYuO2rBk1g==} + eslint-plugin-react-you-might-not-need-an-effect@0.9.3: + resolution: {integrity: sha512-44cce7LndBnpDRWBTQ8p7ircIdl2rJBP5+V9Ik64E935UB47uA9ZMU1Uv160lAMhtvoPYqXBjQ+tojr5JF3mFQ==} engines: {node: '>=14.0.0'} peerDependencies: eslint: '>=8.40.0' @@ -2622,12 +2549,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.0: - resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2669,8 +2596,8 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -2691,8 +2618,8 @@ packages: peerDependencies: express: '>= 4.11' - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2718,11 +2645,23 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - fast-xml-parser@5.3.4: - resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true fastq@1.20.1: @@ -2769,8 +2708,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -2783,8 +2722,8 @@ packages: form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-node@4.4.1: @@ -2799,8 +2738,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.34.1: - resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2817,8 +2756,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.3: - resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} fs.realpath@1.0.0: @@ -2832,8 +2771,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} functions-have-names@1.2.3: @@ -2857,8 +2796,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -2885,8 +2824,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2929,8 +2868,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gzip-size@6.0.0: @@ -2960,12 +2899,12 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2973,8 +2912,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.11.9: - resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -3023,8 +2962,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -3057,8 +2996,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -3074,6 +3013,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3094,8 +3037,8 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-immutable-type@5.0.1: - resolution: {integrity: sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==} + is-immutable-type@5.0.4: + resolution: {integrity: sha512-tDf0vB9dJJt/1USS2nvHJb8UsIAhs+pF6z8UZLNdhrzB+PKMrdcN45je9jhuFpaJOjJpoRhueFmFrRs5g/TNMA==} peerDependencies: eslint: '*' typescript: '>=4.7.4' @@ -3209,9 +3152,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.4: - resolution: {integrity: sha512-jCErc4h4RnTPjFq53G4whhjAMbUAqinGrCrTT4dmMNyi4zTthK+wphqbRLJtL4BN/Mq7Zzltr0m/b1X0m7PGFQ==} - engines: {node: '>=20'} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} @@ -3221,18 +3164,18 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsesc@3.1.0: @@ -3267,8 +3210,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3296,74 +3239,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3391,8 +3334,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3453,28 +3396,20 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - miniflare@4.20260217.0: - resolution: {integrity: sha512-t2v02Vi9SUiiXoHoxLvsntli7N35e/35PuRAYEqHWtHOdDX3bqQ73dBQ0tI12/8ThCb2by2tVs7qOvgwn6xSBQ==} - engines: {node: '>=18.0.0'} + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} + engines: {node: '>=22.0.0'} hasBin: true - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - minimatch@10.2.1: - resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} - engines: {node: 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@8.0.4: - resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} - engines: {node: '>=16 || 14 >=14.17'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -3484,8 +3419,8 @@ packages: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} mkdirp@1.0.4: @@ -3496,14 +3431,14 @@ packages: mnemonist@0.38.3: resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} - motion-dom@12.34.1: - resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} - motion-utils@12.29.2: - resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - motion@12.34.1: - resolution: {integrity: sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA==} + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3519,8 +3454,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.10: - resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + msw@2.14.6: + resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3529,12 +3464,12 @@ packages: typescript: optional: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3560,8 +3495,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.9: + resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3586,6 +3521,10 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3599,8 +3538,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -3720,6 +3660,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3735,15 +3679,15 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3751,12 +3695,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pid-port@2.0.0: @@ -3771,16 +3715,16 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3801,8 +3745,8 @@ packages: vue-tsc: optional: true - prettier-plugin-tailwindcss@0.7.2: - resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + prettier-plugin-tailwindcss@0.7.4: + resolution: {integrity: sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==} engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -3856,8 +3800,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} hasBin: true @@ -3880,8 +3824,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -3895,21 +3839,21 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: - react: ^19.2.4 + react: ^19.2.7 - react-icons@5.5.0: - resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} peerDependencies: react: '*' react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} recast@0.23.11: @@ -3932,8 +3876,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -3942,21 +3886,17 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -3973,8 +3913,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -3995,8 +3935,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} hasBin: true @@ -4008,6 +3948,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4039,8 +3982,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -4051,8 +3994,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -4122,12 +4065,12 @@ packages: string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} engines: {node: '>= 0.4'} string.prototype.trimstart@1.0.8: @@ -4142,8 +4085,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom@3.0.0: @@ -4162,8 +4105,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.1.2: - resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + strnum@2.4.0: + resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -4190,21 +4133,18 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - tailwind-merge@3.4.1: - resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} terser@5.16.9: @@ -4215,19 +4155,19 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} - tldts@7.0.23: - resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} hasBin: true to-regex-range@5.0.1: @@ -4238,15 +4178,15 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -4282,13 +4222,13 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.4.4: - resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -4302,16 +4242,16 @@ packages: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.56.0: - resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -4325,15 +4265,15 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -4351,8 +4291,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -4411,8 +4351,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4429,25 +4369,21 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerd@1.20260217.0: - resolution: {integrity: sha512-6jVisS6wB6KbF+F9DVoDUy9p7MON8qZCFSaL8OcDUioMwknsUPFojUISu3/c30ZOZ24D4h7oqaahFc5C6huilw==} + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} engines: {node: '>=16'} hasBin: true - wrangler@4.66.0: - resolution: {integrity: sha512-b9RVIdKai0BXDuYg0iN0zwVnVbULkvdKGP7Bf1uFY2GhJ/nzDGqgwQbCwgDIOhmaBC8ynhk/p22M2jc8tJy+dQ==} - engines: {node: '>=20.0.0'} + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} + engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260217.0 + '@cloudflare/workers-types': ^4.20260611.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4459,8 +4395,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4475,6 +4411,10 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4482,8 +4422,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -4507,9 +4447,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} + yocto-spinner@1.2.0: + resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} + engines: {node: '>=18.19'} yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} @@ -4521,10 +4461,10 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} @@ -4535,8 +4475,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: @@ -4544,10 +4484,10 @@ snapshots: '@antfu/ni@25.0.0': dependencies: - ansis: 4.2.0 + ansis: 4.3.1 fzf: 0.5.2 package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + tinyexec: 1.2.4 '@ast-grep/napi-darwin-arm64@0.40.5': optional: true @@ -4591,21 +4531,21 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.12 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.12 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.965.4 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4614,15 +4554,15 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.965.4 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.12 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -4631,695 +4571,532 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.12 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/checksums@3.1000.5': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + '@aws-sdk/client-cloudfront@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-host-header': 3.972.21 + '@aws-sdk/middleware-logger': 3.972.20 + '@aws-sdk/middleware-recursion-detection': 3.972.22 + '@aws-sdk/middleware-user-agent': 3.972.50 + '@aws-sdk/region-config-resolver': 3.972.24 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 + '@aws-sdk/util-user-agent-browser': 3.972.21 + '@aws-sdk/util-user-agent-node': 3.973.36 + '@smithy/config-resolver': 4.5.7 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/hash-node': 4.3.7 + '@smithy/invalid-dependency': 4.3.7 + '@smithy/middleware-content-length': 4.3.7 + '@smithy/middleware-endpoint': 4.5.7 + '@smithy/middleware-retry': 4.6.7 + '@smithy/middleware-serde': 4.3.7 + '@smithy/middleware-stack': 4.3.7 + '@smithy/node-config-provider': 4.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/protocol-http': 5.4.7 + '@smithy/smithy-client': 4.13.7 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-base64': 4.4.7 + '@smithy/util-body-length-browser': 4.3.7 + '@smithy/util-body-length-node': 4.3.7 + '@smithy/util-defaults-mode-browser': 4.4.7 + '@smithy/util-defaults-mode-node': 4.3.7 + '@smithy/util-endpoints': 3.5.7 + '@smithy/util-middleware': 4.3.7 + '@smithy/util-retry': 4.4.7 + '@smithy/util-stream': 4.6.7 + '@smithy/util-utf8': 4.3.7 + '@smithy/util-waiter': 4.4.7 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/client-dynamodb@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/dynamodb-codec': 3.972.11 - '@aws-sdk/middleware-endpoint-discovery': 3.972.3 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/dynamodb-codec': 3.973.20 + '@aws-sdk/middleware-endpoint-discovery': 3.972.18 + '@aws-sdk/middleware-host-header': 3.972.21 + '@aws-sdk/middleware-logger': 3.972.20 + '@aws-sdk/middleware-recursion-detection': 3.972.22 + '@aws-sdk/middleware-user-agent': 3.972.50 + '@aws-sdk/region-config-resolver': 3.972.24 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 + '@aws-sdk/util-user-agent-browser': 3.972.21 + '@aws-sdk/util-user-agent-node': 3.973.36 + '@smithy/config-resolver': 4.5.7 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/hash-node': 4.3.7 + '@smithy/invalid-dependency': 4.3.7 + '@smithy/middleware-content-length': 4.3.7 + '@smithy/middleware-endpoint': 4.5.7 + '@smithy/middleware-retry': 4.6.7 + '@smithy/middleware-serde': 4.3.7 + '@smithy/middleware-stack': 4.3.7 + '@smithy/node-config-provider': 4.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/protocol-http': 5.4.7 + '@smithy/smithy-client': 4.13.7 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-base64': 4.4.7 + '@smithy/util-body-length-browser': 4.3.7 + '@smithy/util-body-length-node': 4.3.7 + '@smithy/util-defaults-mode-browser': 4.4.7 + '@smithy/util-defaults-mode-node': 4.3.7 + '@smithy/util-endpoints': 3.5.7 + '@smithy/util-middleware': 4.3.7 + '@smithy/util-retry': 4.4.7 + '@smithy/util-utf8': 4.3.7 + '@smithy/util-waiter': 4.4.7 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/client-lambda@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-host-header': 3.972.21 + '@aws-sdk/middleware-logger': 3.972.20 + '@aws-sdk/middleware-recursion-detection': 3.972.22 + '@aws-sdk/middleware-user-agent': 3.972.50 + '@aws-sdk/region-config-resolver': 3.972.24 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 + '@aws-sdk/util-user-agent-browser': 3.972.21 + '@aws-sdk/util-user-agent-node': 3.973.36 + '@smithy/config-resolver': 4.5.7 + '@smithy/core': 3.24.7 + '@smithy/eventstream-serde-browser': 4.3.7 + '@smithy/eventstream-serde-config-resolver': 4.4.7 + '@smithy/eventstream-serde-node': 4.3.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/hash-node': 4.3.7 + '@smithy/invalid-dependency': 4.3.7 + '@smithy/middleware-content-length': 4.3.7 + '@smithy/middleware-endpoint': 4.5.7 + '@smithy/middleware-retry': 4.6.7 + '@smithy/middleware-serde': 4.3.7 + '@smithy/middleware-stack': 4.3.7 + '@smithy/node-config-provider': 4.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/protocol-http': 5.4.7 + '@smithy/smithy-client': 4.13.7 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-base64': 4.4.7 + '@smithy/util-body-length-browser': 4.3.7 + '@smithy/util-body-length-node': 4.3.7 + '@smithy/util-defaults-mode-browser': 4.4.7 + '@smithy/util-defaults-mode-node': 4.3.7 + '@smithy/util-endpoints': 3.5.7 + '@smithy/util-middleware': 4.3.7 + '@smithy/util-retry': 4.4.7 + '@smithy/util-stream': 4.6.7 + '@smithy/util-utf8': 4.3.7 + '@smithy/util-waiter': 4.4.7 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/client-s3@3.984.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-bucket-endpoint': 3.972.3 - '@aws-sdk/middleware-expect-continue': 3.972.3 - '@aws-sdk/middleware-flexible-checksums': 3.972.8 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-location-constraint': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-s3': 3.972.10 - '@aws-sdk/middleware-ssec': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-bucket-endpoint': 3.972.24 + '@aws-sdk/middleware-expect-continue': 3.972.20 + '@aws-sdk/middleware-flexible-checksums': 3.974.30 + '@aws-sdk/middleware-host-header': 3.972.21 + '@aws-sdk/middleware-location-constraint': 3.972.17 + '@aws-sdk/middleware-logger': 3.972.20 + '@aws-sdk/middleware-recursion-detection': 3.972.22 + '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/middleware-ssec': 3.972.17 + '@aws-sdk/middleware-user-agent': 3.972.50 + '@aws-sdk/region-config-resolver': 3.972.24 '@aws-sdk/signature-v4-multi-region': 3.984.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-blob-browser': 4.2.9 - '@smithy/hash-node': 4.2.8 - '@smithy/hash-stream-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/md5-js': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 + '@aws-sdk/util-user-agent-browser': 3.972.21 + '@aws-sdk/util-user-agent-node': 3.973.36 + '@smithy/config-resolver': 4.5.7 + '@smithy/core': 3.24.7 + '@smithy/eventstream-serde-browser': 4.3.7 + '@smithy/eventstream-serde-config-resolver': 4.4.7 + '@smithy/eventstream-serde-node': 4.3.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/hash-blob-browser': 4.3.7 + '@smithy/hash-node': 4.3.7 + '@smithy/hash-stream-node': 4.3.7 + '@smithy/invalid-dependency': 4.3.7 + '@smithy/md5-js': 4.3.7 + '@smithy/middleware-content-length': 4.3.7 + '@smithy/middleware-endpoint': 4.5.7 + '@smithy/middleware-retry': 4.6.7 + '@smithy/middleware-serde': 4.3.7 + '@smithy/middleware-stack': 4.3.7 + '@smithy/node-config-provider': 4.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/protocol-http': 5.4.7 + '@smithy/smithy-client': 4.13.7 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-base64': 4.4.7 + '@smithy/util-body-length-browser': 4.3.7 + '@smithy/util-body-length-node': 4.3.7 + '@smithy/util-defaults-mode-browser': 4.4.7 + '@smithy/util-defaults-mode-node': 4.3.7 + '@smithy/util-endpoints': 3.5.7 + '@smithy/util-middleware': 4.3.7 + '@smithy/util-retry': 4.4.7 + '@smithy/util-stream': 4.6.7 + '@smithy/util-utf8': 4.3.7 + '@smithy/util-waiter': 4.4.7 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/client-sqs@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-sqs': 3.972.7 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-host-header': 3.972.21 + '@aws-sdk/middleware-logger': 3.972.20 + '@aws-sdk/middleware-recursion-detection': 3.972.22 + '@aws-sdk/middleware-sdk-sqs': 3.972.30 + '@aws-sdk/middleware-user-agent': 3.972.50 + '@aws-sdk/region-config-resolver': 3.972.24 + '@aws-sdk/types': 3.973.12 '@aws-sdk/util-endpoints': 3.984.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/md5-js': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/util-user-agent-browser': 3.972.21 + '@aws-sdk/util-user-agent-node': 3.973.36 + '@smithy/config-resolver': 4.5.7 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/hash-node': 4.3.7 + '@smithy/invalid-dependency': 4.3.7 + '@smithy/md5-js': 4.3.7 + '@smithy/middleware-content-length': 4.3.7 + '@smithy/middleware-endpoint': 4.5.7 + '@smithy/middleware-retry': 4.6.7 + '@smithy/middleware-serde': 4.3.7 + '@smithy/middleware-stack': 4.3.7 + '@smithy/node-config-provider': 4.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/protocol-http': 5.4.7 + '@smithy/smithy-client': 4.13.7 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-base64': 4.4.7 + '@smithy/util-body-length-browser': 4.3.7 + '@smithy/util-body-length-node': 4.3.7 + '@smithy/util-defaults-mode-browser': 4.4.7 + '@smithy/util-defaults-mode-node': 4.3.7 + '@smithy/util-endpoints': 3.5.7 + '@smithy/util-middleware': 4.3.7 + '@smithy/util-retry': 4.4.7 + '@smithy/util-utf8': 4.3.7 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-sso@3.990.0': + '@aws-sdk/core@3.974.20': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/xml-builder': 3.972.29 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.7 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 + bowser: 2.14.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/core@3.973.10': + '@aws-sdk/credential-provider-env@3.972.46': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.4 - '@smithy/core': 3.23.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.0': + '@aws-sdk/credential-provider-http@3.972.48': dependencies: - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.8': + '@aws-sdk/credential-provider-ini@3.972.53': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.52 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/credential-provider-imds': 4.3.9 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.10': + '@aws-sdk/credential-provider-login@3.972.52': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.8': + '@aws-sdk/credential-provider-node@3.972.55': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-env': 3.972.8 - '@aws-sdk/credential-provider-http': 3.972.10 - '@aws-sdk/credential-provider-login': 3.972.8 - '@aws-sdk/credential-provider-process': 3.972.8 - '@aws-sdk/credential-provider-sso': 3.972.8 - '@aws-sdk/credential-provider-web-identity': 3.972.8 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/credential-provider-env': 3.972.46 + '@aws-sdk/credential-provider-http': 3.972.48 + '@aws-sdk/credential-provider-ini': 3.972.53 + '@aws-sdk/credential-provider-process': 3.972.46 + '@aws-sdk/credential-provider-sso': 3.972.52 + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/credential-provider-imds': 4.3.9 + '@smithy/types': 4.14.4 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-login@3.972.8': + '@aws-sdk/credential-provider-process@3.972.46': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-node@3.972.9': + '@aws-sdk/credential-provider-sso@3.972.52': dependencies: - '@aws-sdk/credential-provider-env': 3.972.8 - '@aws-sdk/credential-provider-http': 3.972.10 - '@aws-sdk/credential-provider-ini': 3.972.8 - '@aws-sdk/credential-provider-process': 3.972.8 - '@aws-sdk/credential-provider-sso': 3.972.8 - '@aws-sdk/credential-provider-web-identity': 3.972.8 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/token-providers': 3.1066.0 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-process@3.972.8': + '@aws-sdk/credential-provider-web-identity@3.972.52': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.8': + '@aws-sdk/dynamodb-codec@3.973.20': dependencies: - '@aws-sdk/client-sso': 3.990.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/token-providers': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.8': - dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/dynamodb-codec@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.10 - '@smithy/core': 3.23.0 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 + '@aws-sdk/core': 3.974.20 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/endpoint-cache@3.972.2': + '@aws-sdk/endpoint-cache@3.972.7': dependencies: mnemonist: 0.38.3 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.3': + '@aws-sdk/middleware-bucket-endpoint@3.972.24': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 + '@aws-sdk/middleware-sdk-s3': 3.972.51 tslib: 2.8.1 - '@aws-sdk/middleware-endpoint-discovery@3.972.3': + '@aws-sdk/middleware-endpoint-discovery@3.972.18': dependencies: - '@aws-sdk/endpoint-cache': 3.972.2 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/endpoint-cache': 3.972.7 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.3': + '@aws-sdk/middleware-expect-continue@3.972.20': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.51 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.972.8': + '@aws-sdk/middleware-flexible-checksums@3.974.30': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/crc64-nvme': 3.972.0 - '@aws-sdk/types': 3.973.1 - '@smithy/is-array-buffer': 4.2.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/checksums': 3.1000.5 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.3': + '@aws-sdk/middleware-host-header@3.972.21': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.3': + '@aws-sdk/middleware-location-constraint@3.972.17': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.51 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.3': + '@aws-sdk/middleware-logger@3.972.20': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.3': + '@aws-sdk/middleware-recursion-detection@3.972.22': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.10': + '@aws-sdk/middleware-sdk-s3@3.972.51': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.23.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-sqs@3.972.7': + '@aws-sdk/middleware-sdk-sqs@3.972.30': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.3': + '@aws-sdk/middleware-ssec@3.972.17': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.51 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.10': + '@aws-sdk/middleware-user-agent@3.972.50': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@smithy/core': 3.23.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.990.0': + '@aws-sdk/nested-clients@3.997.20': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/region-config-resolver@3.972.3': + '@aws-sdk/region-config-resolver@3.972.24': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.984.0': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.10 - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.51 + '@aws-sdk/types': 3.973.12 + '@smithy/protocol-http': 5.4.7 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/token-providers@3.990.0': + '@aws-sdk/signature-v4-multi-region@3.996.34': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.973.1': - dependencies: - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.12 + '@smithy/signature-v4': 5.4.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.2': + '@aws-sdk/token-providers@3.1066.0': dependencies: + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.12': + dependencies: + '@smithy/types': 4.14.4 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.984.0': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/types': 3.973.12 + '@smithy/types': 4.14.4 + '@smithy/url-parser': 4.3.7 + '@smithy/util-endpoints': 3.5.7 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.990.0': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-locate-window@3.965.4': + '@aws-sdk/util-locate-window@3.965.7': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/util-user-agent-browser@3.972.21': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - bowser: 2.14.1 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.8': + '@aws-sdk/util-user-agent-node@3.973.36': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.20 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.4': + '@aws-sdk/xml-builder@3.972.29': dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.4 + '@smithy/types': 4.14.4 + fast-xml-parser: 5.7.3 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.3': {} + '@aws/lambda-invoke-store@0.2.4': {} - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -5329,211 +5106,210 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': + '@babel/helper-annotate-as-pure@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.28.5': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.27.1': + '@babel/helper-optimise-call-expression@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.29.0': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@babel/runtime': 7.28.6 - '@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/utils': 0.2.10 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tabbable: 6.4.0 - use-sync-external-store: 1.6.0(react@19.2.4) + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.2.9(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@base-ui/utils@0.2.9(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@babel/runtime': 7.28.6 - '@floating-ui/utils': 0.2.10 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@19.2.4) + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@cloudflare/kv-asset-handler@0.4.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.13.0(unenv@2.0.0-rc.24)(workerd@1.20260217.0)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260217.0 + workerd: 1.20260611.1 - '@cloudflare/workerd-darwin-64@1.20260217.0': + '@cloudflare/workerd-darwin-64@1.20260611.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260217.0': + '@cloudflare/workerd-darwin-arm64@1.20260611.1': optional: true - '@cloudflare/workerd-linux-64@1.20260217.0': + '@cloudflare/workerd-linux-64@1.20260611.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260217.0': + '@cloudflare/workerd-linux-arm64@1.20260611.1': optional: true - '@cloudflare/workerd-windows-64@1.20260217.0': + '@cloudflare/workerd-windows-64@1.20260611.1': optional: true '@cspotcode/source-map-support@0.8.1': @@ -5544,42 +5320,49 @@ snapshots: dependencies: commander: 11.1.0 dotenv: 16.6.1 - eciesjs: 0.4.17 + eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.4 which: 4.0.0 - '@dotenvx/dotenvx@1.52.0': + '@dotenvx/dotenvx@1.71.3': dependencies: commander: 11.1.0 - dotenv: 17.3.1 - eciesjs: 0.4.17 + dotenv: 17.4.2 + eciesjs: 0.4.18 + enquirer: 2.4.1 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.4 which: 4.0.0 + yocto-spinner: 1.2.0 - '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.8.1': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -5737,35 +5520,35 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/ast@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) string-ts: 2.3.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/core@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: @@ -5773,56 +5556,56 @@ snapshots: '@eslint-react/eff@2.13.0': {} - '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-react-dom: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-rsc: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-web-api: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-x: 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - ts-api-utils: 2.4.0(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + eslint-plugin-react-dom: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-react-rsc: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-react-web-api: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint-plugin-react-x: 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/shared@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 - zod: 4.3.6 + zod: 4.4.3 transitivePeerDependencies: - supports-color - '@eslint-react/var@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/var@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -5834,21 +5617,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 + js-yaml: 4.2.0 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -5857,39 +5640,44 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.4': + '@floating-ui/core@1.7.5': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.5': + '@floating-ui/dom@1.7.6': dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} - '@hono/node-server@1.19.9(hono@4.11.9)': + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: - hono: 4.11.9 + hono: 4.12.25 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -5973,7 +5761,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -5985,39 +5773,32 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.7': {} - '@inquirer/confirm@5.1.21(@types/node@25.2.3)': + '@inquirer/confirm@6.1.1(@types/node@25.9.3)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.2.3) - '@inquirer/type': 3.0.10(@types/node@25.2.3) + '@inquirer/core': 11.2.1(@types/node@25.9.3) + '@inquirer/type': 4.0.7(@types/node@25.9.3) optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.9.3 - '@inquirer/core@10.3.2(@types/node@25.2.3)': + '@inquirer/core@11.2.1(@types/node@25.9.3)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.2.3) + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.3) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.9.3 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.7': {} - '@inquirer/type@3.0.10(@types/node@25.2.3)': + '@inquirer/type@4.0.7(@types/node@25.9.3)': optionalDependencies: - '@types/node': 25.2.3 - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@types/node': 25.9.3 '@isaacs/cliui@9.0.0': {} @@ -6052,46 +5833,46 @@ snapshots: '@modelcontextprotocol/sdk@1.24.3(zod@3.25.76)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 7.5.1(express@5.2.1) - jose: 6.1.3 + jose: 6.2.3 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.9 - jose: 6.1.3 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.3': + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -6100,41 +5881,41 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@0.2.12': + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 optional: true - '@next/env@16.1.6': {} + '@next/env@16.2.9': {} - '@next/eslint-plugin-next@16.1.6': + '@next/eslint-plugin-next@16.2.9': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.2.9': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.2.9': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.2.9': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.2.9': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.2.9': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.2.9': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.2.9': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.2.9': optional: true '@noble/ciphers@1.3.0': {} @@ -6145,6 +5926,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@2.2.0': {} + '@node-minify/core@8.0.6': dependencies: '@node-minify/utils': 8.0.6 @@ -6176,6 +5959,8 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 @@ -6183,7 +5968,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opennextjs/aws@3.9.16(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@opennextjs/aws@4.0.2(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0 @@ -6199,28 +5984,28 @@ snapshots: cookie: 1.1.1 esbuild: 0.25.4 express: 5.2.1 - next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 - yaml: 2.8.2 + yaml: 2.9.0 transitivePeerDependencies: - - aws-crt - supports-color - '@opennextjs/cloudflare@1.16.5(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.66.0)': + '@opennextjs/cloudflare@1.19.11(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(wrangler@4.100.0)': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.9.16(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@opennextjs/aws': 4.0.2(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + ci-info: 4.4.0 cloudflare: 4.5.0 + comment-json: 4.6.2 enquirer: 2.4.1 glob: 12.0.0 - next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ts-tqdm: 0.8.6 - wrangler: 4.66.0 + wrangler: 4.100.0 yargs: 18.0.0 transitivePeerDependencies: - - aws-crt - encoding - supports-color @@ -6244,255 +6029,148 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/abort-controller@4.2.8': + '@smithy/config-resolver@4.5.7': dependencies: - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/chunked-blob-reader-native@4.2.1': - dependencies: - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader@5.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/core@3.23.0': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.2.8': + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.8': + '@smithy/credential-provider-imds@4.3.9': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.8': + '@smithy/eventstream-serde-browser@4.3.7': dependencies: - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.8': + '@smithy/eventstream-serde-config-resolver@4.4.7': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.8': + '@smithy/eventstream-serde-node@4.3.7': dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.9': + '@smithy/fetch-http-handler@5.4.7': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 tslib: 2.8.1 - '@smithy/hash-blob-browser@4.2.9': + '@smithy/hash-blob-browser@4.3.7': dependencies: - '@smithy/chunked-blob-reader': 5.2.0 - '@smithy/chunked-blob-reader-native': 4.2.1 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/hash-node@4.2.8': + '@smithy/hash-node@4.3.7': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/hash-stream-node@4.2.8': + '@smithy/hash-stream-node@4.3.7': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.8': + '@smithy/invalid-dependency@4.3.7': dependencies: - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': + '@smithy/md5-js@4.3.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.3.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.5.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.6.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.3.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.3.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.4.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.8': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/protocol-http@5.4.7': + dependencies: + '@smithy/core': 3.24.7 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.7': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/smithy-client@4.13.7': + dependencies: + '@smithy/core': 3.24.7 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + + '@smithy/types@4.14.4': dependencies: tslib: 2.8.1 - '@smithy/md5-js@4.2.8': + '@smithy/url-parser@4.3.7': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.8': + '@smithy/util-base64@4.4.7': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.14': + '@smithy/util-body-length-browser@4.3.7': dependencies: - '@smithy/core': 3.23.0 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.31': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.8': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.4.10': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/protocol-http@5.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.8': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.11.3': - dependencies: - '@smithy/core': 3.23.0 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - - '@smithy/types@4.12.0': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@4.2.8': - dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.2.1': + '@smithy/util-body-length-node@4.3.7': dependencies: + '@smithy/core': 3.24.7 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -6500,66 +6178,34 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': + '@smithy/util-defaults-mode-browser@4.4.7': dependencies: - '@smithy/is-array-buffer': 4.2.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': + '@smithy/util-defaults-mode-node@4.3.7': dependencies: + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.30': + '@smithy/util-endpoints@3.5.7': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.33': + '@smithy/util-middleware@4.3.7': dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': + '@smithy/util-retry@4.4.7': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-middleware@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-retry@4.2.8': - dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.12': - dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.2.0': + '@smithy/util-stream@4.6.7': dependencies: + '@smithy/core': 3.24.7 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -6567,110 +6213,105 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': + '@smithy/util-utf8@4.3.7': dependencies: - '@smithy/util-buffer-from': 4.2.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/util-waiter@4.2.8': + '@smithy/util-waiter@4.4.7': dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.24.7 tslib: 2.8.1 - '@smithy/uuid@1.1.0': - dependencies: - tslib: 2.8.1 - - '@speed-highlight/core@1.2.14': {} + '@speed-highlight/core@1.2.17': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.30.2 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.3.1 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.3.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.3.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.3.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.3.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/postcss@4.1.18': + '@tailwindcss/postcss@4.3.1': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.6 - tailwindcss: 4.1.18 + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + postcss: 8.5.15 + tailwindcss: 4.3.1 '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.1 + minimatch: 10.2.5 path-browserify: 1.0.1 '@tsconfig/node18@1.0.3': {} - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -6678,240 +6319,192 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.2.3 - form-data: 4.0.5 + '@types/node': 25.9.3 + form-data: 4.0.6 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@25.2.3': + '@types/node@25.9.3': dependencies: - undici-types: 7.16.0 + undici-types: 7.24.6 - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.17 - '@types/react@19.2.14': + '@types/react@19.2.17': dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.9.3 + '@types/statuses@2.0.6': {} '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.61.0': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 + '@typescript-eslint/types@8.61.0': {} - '@typescript-eslint/scope-manager@8.56.0': + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 - - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.55.0': {} - - '@typescript-eslint/types@8.56.0': {} - - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': + '@typescript-eslint/visitor-keys@8.61.0': dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': dependencies: - '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 - - '@typescript-eslint/visitor-keys@8.56.0': - dependencies: - '@typescript-eslint/types': 8.56.0 - eslint-visitor-keys: 5.0.0 - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@unrs/resolver-binding-android-arm64@1.11.1': + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': optional: true - '@unrs/resolver-binding-darwin-arm64@1.11.1': + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': optional: true - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true abort-controller@3.0.0: @@ -6923,11 +6516,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.15.0 + acorn: 8.17.0 - acorn@8.15.0: {} + acorn@8.17.0: {} agent-base@7.1.4: {} @@ -6935,32 +6528,21 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6976,7 +6558,9 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.2.0: {} + ansis@4.3.1: {} + + anynum@1.0.0: {} argparse@2.0.1: {} @@ -6989,62 +6573,64 @@ snapshots: array-includes@3.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 + array-timsort@1.0.3: {} + array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -7065,21 +6651,19 @@ snapshots: aws4fetch@1.0.20: {} - axe-core@4.11.1: {} + axe-core@4.12.1: {} axobject-query@4.1.0: {} babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 balanced-match@1.0.2: {} - balanced-match@4.0.2: - dependencies: - jackspeak: 4.2.3 + balanced-match@4.0.4: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.37: {} birecord@0.1.1: {} @@ -7093,38 +6677,38 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.1 + qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color bowser@2.14.1: {} - brace-expansion@1.1.12: + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: + brace-expansion@5.0.6: dependencies: - balanced-match: 4.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.372 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-from@1.1.2: {} @@ -7139,7 +6723,7 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -7153,7 +6737,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001799: {} chalk@4.1.2: dependencies: @@ -7162,6 +6746,8 @@ snapshots: chalk@5.6.2: {} + ci-info@4.4.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7185,7 +6771,7 @@ snapshots: cliui@9.0.1: dependencies: string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi: 9.0.2 cloudflare@4.5.0: @@ -7222,14 +6808,21 @@ snapshots: commander@2.20.3: {} + comment-json@4.6.2: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + compare-versions@6.1.1: {} concat-map@0.0.1: {} - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -7243,11 +6836,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.2(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -7292,7 +6885,7 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.1: {} + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -7325,7 +6918,7 @@ snapshots: detect-libc@2.1.2: {} - diff@8.0.3: {} + diff@8.0.4: {} doctrine@2.1.0: dependencies: @@ -7333,7 +6926,7 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.3.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -7343,16 +6936,16 @@ snapshots: duplexer@0.1.2: {} - eciesjs@0.4.17: + eciesjs@0.4.18: dependencies: - '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) '@noble/ciphers': 1.3.0 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.372: {} emoji-regex@10.6.0: {} @@ -7362,10 +6955,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.19.0: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 enquirer@2.4.1: dependencies: @@ -7380,22 +6973,22 @@ snapshots: error-stack-parser-es@1.0.5: {} - es-abstract@1.24.1: + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -7404,7 +6997,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -7422,31 +7015,31 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.2: + es-iterator-helpers@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -7458,9 +7051,9 @@ snapshots: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 + math-intrinsics: 1.1.0 - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -7469,11 +7062,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -7544,18 +7137,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + '@next/eslint-plugin-next': 16.2.9 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7564,45 +7157,45 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - get-tsconfig: 4.13.6 + eslint: 9.39.4(jiti@2.7.0) + get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7611,182 +7204,182 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + hasown: 2.0.4 + is-core-module: 2.16.2 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 semver: 6.3.1 - string.prototype.trimend: 1.0.9 + string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.1 + axe-core: 4.12.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) - hasown: 2.0.2 + eslint: 9.39.4(jiti@2.7.0) + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-dom@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-dom@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.7.0)): dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 9.39.2(jiti@2.6.1) + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) string-ts: 2.3.1 ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-rsc@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-rsc@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-web-api@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) birecord: 0.1.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-x@2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.39.2(jiti@2.6.1) - is-immutable-type: 5.0.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - ts-api-utils: 2.4.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + is-immutable-type: 5.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-you-might-not-need-an-effect@0.9.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-you-might-not-need-an-effect@0.9.3(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.7.0) globals: 16.5.0 - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) + es-iterator-helpers: 1.3.3 + eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.5 + resolve: 2.0.0-next.7 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -7800,23 +7393,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.0: {} + eslint-visitor-keys@5.0.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.4(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -7835,18 +7428,18 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -7867,11 +7460,11 @@ snapshots: event-target-shim@5.0.1: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 execa@5.1.1: dependencies: @@ -7904,16 +7497,16 @@ snapshots: dependencies: express: 5.2.1 - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.2.0 express@5.2.1: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.0.1 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -7931,13 +7524,13 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.1 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -7964,19 +7557,37 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-string-truncated-width@3.0.3: {} - fast-xml-parser@5.3.4: + fast-string-width@3.0.2: dependencies: - strnum: 2.1.2 + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.4.0 fastq@1.20.1: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fetch-blob@3.2.0: dependencies: @@ -8019,10 +7630,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} for-each@0.3.5: dependencies: @@ -8035,12 +7646,12 @@ snapshots: form-data-encoder@1.7.2: {} - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formdata-node@4.4.1: @@ -8054,21 +7665,21 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - motion-dom: 12.34.1 - motion-utils: 12.29.2 + motion-dom: 12.40.0 + motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) fresh@2.0.0: {} - fs-extra@11.3.3: + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fs.realpath@1.0.0: {} @@ -8078,14 +7689,17 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: + function.prototype.name@1.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 - define-properties: 1.2.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 functions-have-names: 1.2.3 - hasown: 2.0.2 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 is-callable: 1.2.7 + is-document.all: 1.0.0 functions-have-names@1.2.3: {} @@ -8099,19 +7713,19 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-own-enumerable-keys@1.0.0: {} @@ -8119,7 +7733,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@6.0.1: {} @@ -8134,7 +7748,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -8150,15 +7764,15 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 10.1.2 - minipass: 7.1.2 + minimatch: 10.2.5 + minipass: 7.1.3 package-json-from-dist: 1.0.1 - path-scurry: 2.0.1 + path-scurry: 2.0.2 glob@9.3.5: dependencies: fs.realpath: 1.0.0 - minimatch: 8.0.4 + minimatch: 8.0.7 minipass: 4.2.8 path-scurry: 1.11.1 @@ -8177,7 +7791,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.12.0: {} + graphql@16.14.2: {} gzip-size@6.0.0: dependencies: @@ -8201,11 +7815,14 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 - headers-polyfill@4.0.3: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 hermes-estree@0.25.1: {} @@ -8213,7 +7830,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.11.9: {} + hono@4.12.25: {} http-errors@2.0.1: dependencies: @@ -8258,16 +7875,16 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 - ip-address@10.0.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -8292,13 +7909,13 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.4 is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.16.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -8313,6 +7930,10 @@ snapshots: is-docker@3.0.0: {} + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -8333,11 +7954,11 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-immutable-type@5.0.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + is-immutable-type@5.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) ts-declaration-location: 1.0.7(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -8375,7 +7996,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-regexp@3.1.0: {} @@ -8402,7 +8023,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 is-unicode-supported@1.3.0: {} @@ -8427,12 +8048,12 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.4: {} + isexe@3.1.5: {} iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 @@ -8442,13 +8063,13 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jiti@2.6.1: {} + jiti@2.7.0: {} - jose@6.1.3: {} + jose@6.2.3: {} js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -8472,7 +8093,7 @@ snapshots: json5@2.2.3: {} - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -8504,54 +8125,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.30.2: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lines-and-columns@1.2.4: {} @@ -8574,15 +8195,15 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.6: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.574.0(react@19.2.4): + lucide-react@0.574.0(react@19.2.7): dependencies: - react: 19.2.4 + react: 19.2.7 magic-string@0.30.21: dependencies: @@ -8601,7 +8222,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -8619,43 +8240,35 @@ snapshots: mimic-function@5.0.1: {} - miniflare@4.20260217.0: + miniflare@4.20260611.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.18.2 - workerd: 1.20260217.0 - ws: 8.18.0 + undici: 7.24.8 + workerd: 1.20260611.1 + ws: 8.20.1 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil - utf-8-validate - minimatch@10.1.2: + minimatch@10.2.5: dependencies: - '@isaacs/brace-expansion': 5.0.1 + brace-expansion: 5.0.6 - minimatch@10.2.1: + minimatch@3.1.5: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 1.1.15 - minimatch@3.1.2: + minimatch@8.0.7: dependencies: - brace-expansion: 1.1.12 - - minimatch@8.0.4: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.1 minimist@1.2.8: {} minipass@4.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} mkdirp@1.0.4: {} @@ -8663,40 +8276,40 @@ snapshots: dependencies: obliterator: 1.6.1 - motion-dom@12.34.1: + motion-dom@12.40.0: dependencies: - motion-utils: 12.29.2 + motion-utils: 12.39.0 - motion-utils@12.29.2: {} + motion-utils@12.39.0: {} - motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - framer-motion: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) ms@2.1.3: {} - msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3): + msw@2.14.6(@types/node@25.9.3)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.2.3) - '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@inquirer/confirm': 6.1.1(@types/node@25.9.3) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.12.0 - headers-polyfill: 4.0.3 + graphql: 16.14.2 + headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.10.1 + rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 - type-fest: 5.4.4 + tough-cookie: 6.0.1 + type-fest: 5.7.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -8704,9 +8317,9 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} napi-postinstall@0.3.4: {} @@ -8725,30 +8338,30 @@ snapshots: - '@cfworker/json-schema' - supports-color - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.9 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.9 + '@next/swc-darwin-x64': 16.2.9 + '@next/swc-linux-arm64-gnu': 16.2.9 + '@next/swc-linux-arm64-musl': 16.2.9 + '@next/swc-linux-x64-gnu': 16.2.9 + '@next/swc-linux-x64-musl': 16.2.9 + '@next/swc-win32-arm64-msvc': 16.2.9 + '@next/swc-win32-x64-msvc': 16.2.9 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -8757,6 +8370,13 @@ snapshots: node-domexception@1.0.0: {} + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -8767,7 +8387,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.27: {} + node-releases@2.0.47: {} npm-run-path@4.0.1: dependencies: @@ -8788,39 +8408,39 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 obliterator@1.6.1: {} @@ -8868,7 +8488,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 outvariant@1.4.3: {} @@ -8896,7 +8516,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -8909,6 +8529,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -8918,24 +8540,24 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 - path-scurry@2.0.1: + path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 - minipass: 7.1.2 + lru-cache: 11.5.1 + minipass: 7.1.3 path-to-regexp@6.3.0: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pid-port@2.0.0: dependencies: @@ -8945,20 +8567,20 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@7.1.1: + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 postcss@8.4.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: + postcss@8.5.15: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8966,18 +8588,18 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3): dependencies: - prettier: 3.8.1 + prettier: 3.8.4 typescript: 5.9.3 - prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1): + prettier-plugin-tailwindcss@0.7.4(prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3))(prettier@3.8.4): dependencies: - prettier: 3.8.1 + prettier: 3.8.4 optionalDependencies: - prettier-plugin-organize-imports: 4.3.0(prettier@3.8.1)(typescript@5.9.3) + prettier-plugin-organize-imports: 4.3.0(prettier@3.8.4)(typescript@5.9.3) - prettier@3.8.1: {} + prettier@3.8.4: {} pretty-ms@9.3.0: dependencies: @@ -9001,9 +8623,9 @@ snapshots: punycode@2.3.1: {} - qs@6.14.1: + qs@6.15.2: dependencies: - side-channel: 1.1.0 + side-channel: 1.1.1 queue-microtask@1.2.3: {} @@ -9016,18 +8638,18 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.7(react@19.2.7): dependencies: - react: 19.2.4 + react: 19.2.7 scheduler: 0.27.0 - react-icons@5.5.0(react@19.2.4): + react-icons@5.6.0(react@19.2.7): dependencies: - react: 19.2.4 + react: 19.2.7 react-is@16.13.1: {} - react@19.2.4: {} + react@19.2.7: {} recast@0.23.11: dependencies: @@ -9039,18 +8661,18 @@ snapshots: reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -9061,21 +8683,18 @@ snapshots: require-from-string@2.0.2: {} - reselect@5.1.1: {} + reselect@5.2.0: {} resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: + resolve@2.0.0-next.7: dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.5: - dependencies: - is-core-module: 2.16.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -9084,7 +8703,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.10.1: {} + rettime@0.11.11: {} reusify@1.1.0: {} @@ -9094,7 +8713,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -9104,9 +8723,9 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -9129,7 +8748,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.8.4: {} send@1.2.1: dependencies: @@ -9156,6 +8775,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -9176,47 +8797,47 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 setprototypeof@1.2.0: {} - shadcn@3.8.5(@types/node@25.2.3)(typescript@5.9.3): + shadcn@3.8.5(@types/node@25.9.3)(typescript@5.9.3): dependencies: '@antfu/ni': 25.0.0 - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.52.0 - '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@dotenvx/dotenvx': 1.71.3 + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.1 + browserslist: 4.28.2 commander: 14.0.3 - cosmiconfig: 9.0.0(typescript@5.9.3) - dedent: 1.7.1 + cosmiconfig: 9.0.2(typescript@5.9.3) + dedent: 1.7.2 deepmerge: 4.3.1 - diff: 8.0.3 + diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.3 + fs-extra: 11.3.5 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.10(@types/node@25.2.3)(typescript@5.9.3) + msw: 2.14.6(@types/node@25.9.3)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.6 - postcss-selector-parser: 7.1.1 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 prompts: 2.4.2 recast: 0.23.11 stringify-object: 5.0.0 - tailwind-merge: 3.4.1 + tailwind-merge: 3.6.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -9226,9 +8847,9 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -9261,7 +8882,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -9281,11 +8902,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -9295,10 +8916,10 @@ snapshots: sisteransi@1.0.5: {} - sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) source-map-js@1.2.1: {} @@ -9333,58 +8954,59 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 string.prototype.matchall@4.0.12: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 - string.prototype.trim@1.2.10: + string.prototype.trim@1.2.11: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 - string.prototype.trimend@1.0.9: + string.prototype.trimend@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 stringify-object@5.0.0: dependencies: @@ -9396,7 +9018,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -9408,14 +9030,16 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@2.1.2: {} + strnum@2.4.0: + dependencies: + anynum: 1.0.0 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.2.7 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 supports-color@10.2.2: {} @@ -9425,37 +9049,35 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tabbable@6.4.0: {} - tagged-tag@1.0.0: {} - tailwind-merge@3.4.1: {} + tailwind-merge@3.6.0: {} - tailwindcss@4.1.18: {} + tailwindcss@4.3.1: {} - tapable@2.3.0: {} + tapable@2.3.3: {} terser@5.16.9: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 tiny-invariant@1.3.3: {} - tinyexec@1.0.2: {} + tinyexec@1.2.4: {} - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tldts-core@7.0.23: {} + tldts-core@7.4.2: {} - tldts@7.0.23: + tldts@7.4.2: dependencies: - tldts-core: 7.0.23 + tldts-core: 7.4.2 to-regex-range@5.0.1: dependencies: @@ -9463,19 +9085,19 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@6.0.0: + tough-cookie@6.0.1: dependencies: - tldts: 7.0.23 + tldts: 7.4.2 tr46@0.0.3: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 typescript: 5.9.3 ts-morph@26.0.0: @@ -9508,13 +9130,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.4.4: + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 @@ -9526,7 +9148,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -9535,29 +9157,29 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.7: + typed-array-length@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9573,11 +9195,11 @@ snapshots: undici-types@5.26.5: {} - undici-types@7.16.0: {} + undici-types@7.24.6: {} undici@7.16.0: {} - undici@7.18.2: {} + undici@7.24.8: {} unenv@2.0.0-rc.24: dependencies: @@ -9589,35 +9211,38 @@ snapshots: unpipe@1.0.0: {} - unrs-resolver@1.11.1: + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -9627,9 +9252,9 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-sync-external-store@1.6.0(react@19.2.4): + use-sync-external-store@1.6.0(react@19.2.7): dependencies: - react: 19.2.4 + react: 19.2.7 util-deprecate@1.0.2: {} @@ -9659,7 +9284,7 @@ snapshots: which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -9670,7 +9295,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 which-collection@1.0.2: dependencies: @@ -9679,10 +9304,10 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.20: + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -9695,40 +9320,34 @@ snapshots: which@4.0.0: dependencies: - isexe: 3.1.4 + isexe: 3.1.5 word-wrap@1.2.5: {} - workerd@1.20260217.0: + workerd@1.20260611.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260217.0 - '@cloudflare/workerd-darwin-arm64': 1.20260217.0 - '@cloudflare/workerd-linux-64': 1.20260217.0 - '@cloudflare/workerd-linux-arm64': 1.20260217.0 - '@cloudflare/workerd-windows-64': 1.20260217.0 + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 - wrangler@4.66.0: + wrangler@4.100.0: dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.13.0(unenv@2.0.0-rc.24)(workerd@1.20260217.0) + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) blake3-wasm: 2.1.5 esbuild: 0.27.3 - miniflare: 4.20260217.0 + miniflare: 4.20260611.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260217.0 + workerd: 1.20260611.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - utf-8-validate - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9739,22 +9358,24 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} - ws@8.18.0: {} + ws@8.20.1: {} wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-naming@0.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} - yaml@2.8.2: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} @@ -9781,7 +9402,9 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} + yocto-spinner@1.2.0: + dependencies: + yoctocolors: 2.1.2 yoctocolors@2.1.2: {} @@ -9794,18 +9417,18 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.14 + '@speed-highlight/core': 1.2.17 cookie: 1.1.1 youch-core: 0.3.3 - zod-to-json-schema@3.25.1(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.3.6): + zod-validation-error@4.0.2(zod@4.4.3): dependencies: - zod: 4.3.6 + zod: 4.4.3 zod@3.25.76: {} - zod@4.3.6: {} + zod@4.4.3: {} From 6c969b0d970d4fafa5c74d405b4995d31cdaf4ef Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 13 Jun 2026 16:18:02 -0700 Subject: [PATCH 13/67] release v5.1.0 --- assets/release-notes.md | 32 ++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index ff43aef5..6bc537b8 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,35 @@ +## v5.1.0 + +June 13, 2026 + +### New Features + +- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery. +- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together. +- Added new chat settings for keeping the screen on and showing timestamps on historical messages. +- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels. + +### Improvements + +- Improved chat send reliability, especially when chat delay is enabled. +- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages. +- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks. +- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options. + +### Bug Fixes + +- Fixed recent messages failing on valueless or escaped IRC tags. +- Fixed shared chat badges and emotes lingering after leaving shared chat. +- Fixed recent searches not saving from type-to-search. +- Fixed auto-synced chat delay not applying when toggled on mid-session. +- Fixed long chat inputs overlapping the message list. +- Fixed a video refresh race that could leave a black screen with only the play button visible. +- Fixed fullscreen button and system UI state getting out of sync. +- Fixed landscape safe area and iPad gesture padding issues. +- Fixed Twitch/API auth requests that could hang instead of failing cleanly. + +--- + ## v5.0.5 January 9, 2026 diff --git a/pubspec.yaml b/pubspec.yaml index 6ecbaa06..962a8bf4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.0.5+68 +version: 5.1.0+69 environment: sdk: ^3.9.0 From ef1b1ded5d6044f5f23b58c5061552ee01ff8855 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 21 Jun 2026 15:04:11 -0400 Subject: [PATCH 14/67] fix: reclassify transient errors as non-fatal and fix top crashes - main: record transient I/O, plugin-teardown, and non-crashing UI errors (network/websocket/timeout, MissingPluginException, image-load, layout overflow) as non-fatal, and add a grouping reason to async errors so distinct errors stop collapsing into one catch-all Crashlytics issue - irc: guard IRCMessage.fromString against malformed/truncated lines that threw RangeError, with regression tests - chat: wrap the emote menu in OverflowBox so it no longer overflows (RenderFlex) while the panel collapses - chat: fix onBadgeError returning a List instead of a Map, which threw a Future.catchError type error during channel asset loads - video: skip setAutoPipMode on Android < 12 where it isn't implemented Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/main.dart | 52 +++++++- lib/models/irc.dart | 14 ++ lib/screens/channel/chat/chat.dart | 124 ++++++++++-------- .../channel/chat/stores/chat_store.dart | 9 +- lib/screens/channel/video/video_store.dart | 9 +- test/models/irc_test.dart | 18 +++ 6 files changed, 156 insertions(+), 70 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6941eca0..dd221dff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,20 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:app_links/app_links.dart'; +import 'package:dio/dio.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:frosty/apis/base_api_client.dart' show ApiException; import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/ffz_api.dart'; @@ -35,6 +39,25 @@ import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Errors that are expected on a mobile client — transient network/I/O, plugin +/// teardown races, and image fetch failures — and therefore shouldn't be +/// reported to Crashlytics as fatal crashes (which would tank the crash-free +/// metric and bucket unrelated errors together under the global handlers). +/// +/// This is a last-resort net. The layers that own these errors already record +/// them non-fatally with precise reasons (e.g. base_api_client, the IRC +/// websocket handlers); this only catches what escapes that local handling. +bool _isNonFatalError(Object error) { + return error is SocketException || + error is HttpException || + error is WebSocketChannelException || + error is ApiException || + error is MissingPluginException || + error is NetworkImageLoadException || + (error is DioException && error.type != DioExceptionType.badResponse); +} void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -43,12 +66,33 @@ void main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - // Pass all uncaught "fatal" errors from the framework to Crashlytics - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + // Route uncaught framework errors to Crashlytics. Non-crashing UI errors + // (layout overflows, image-load failures) and other expected transient + // failures are recorded as non-fatal so they don't inflate the crash count. + FlutterError.onError = (details) { + // Run the cheap type checks first; only fall back to the diagnostics string + // (RenderFlex overflow has no typed signal) when those miss. + final isNonFatal = + _isNonFatalError(details.exception) || + details.exceptionAsString().contains('overflowed'); + if (isNonFatal) { + FirebaseCrashlytics.instance.recordFlutterError(details); + } else { + FirebaseCrashlytics.instance.recordFlutterFatalError(details); + } + }; - // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics + // Pass uncaught asynchronous errors to Crashlytics. Expected transient errors + // are recorded non-fatally; everything else stays fatal. The reason gives the + // grouping engine a real signal so distinct errors don't collapse into one + // catch-all issue under this handler frame. PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + FirebaseCrashlytics.instance.recordError( + error, + stack, + reason: error.runtimeType.toString(), + fatal: !_isNonFatalError(error), + ); return true; }; diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 4f49f28c..8e94cbb3 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -974,6 +974,12 @@ class IRCMessage { // Obtain the index to break off the tags. final tagAndIrcMessageDivider = whole.indexOf(' '); + // Without a separating space there are no tags to slice and no message body + // to parse — bail out rather than throw on the substring/index calls below. + if (tagAndIrcMessageDivider == -1) { + return IRCMessage(raw: whole, command: Command.none, tags: const {}); + } + // Parse the IRCv3 tags into a map. The tags section is everything between // the leading '@' and the first space. We split on the *raw* string: a // literal ';' is always a separator because escaped semicolons inside a @@ -1006,6 +1012,14 @@ class IRCMessage { // Index 3 and beyond is the :message (word by word) that is sent by the user or empty depending on the command final splitMessage = ircMessage.split(' '); + // A well-formed message is at least ` `. A malformed or + // truncated line (e.g. from the recent-messages history API) can split into + // a single token, which would otherwise throw a RangeError on the + // splitMessage[0]/[1] accesses below. + if (splitMessage.length < 2) { + return IRCMessage(raw: whole, command: Command.none, tags: mappedTags); + } + // If the username exists, set it. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. final prefix = splitMessage[0]; diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 135940ed..70d14750 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -266,76 +266,84 @@ class Chat extends StatelessWidget { } Widget _buildEmoteMenu(BuildContext context) { + final menuHeight = context.screenHeight / (context.isPortrait ? 3 : 2); return AnimatedContainer( curve: Curves.ease, duration: const Duration(milliseconds: 200), - height: chatStore.assetsStore.showEmoteMenu - ? context.screenHeight / (context.isPortrait ? 3 : 2) - : 0, + height: chatStore.assetsStore.showEmoteMenu ? menuHeight : 0, child: AnimatedSwitcher( duration: const Duration(milliseconds: 100), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: chatStore.assetsStore.showEmoteMenu ? ClipRect( - child: Column( - children: [ - const Divider(), - Expanded( - child: FrostyPageView( - headers: [ - 'Recent', - if (chatStore.settings.showTwitchEmotes) - 'Twitch', - if (chatStore.settings.show7TVEmotes) '7TV', - if (chatStore.settings.showBTTVEmotes) 'BTTV', - if (chatStore.settings.showFFZEmotes) 'FFZ', - ], - tabActions: { - if (chatStore - .assetsStore - .recentEmotes - .isNotEmpty) - 0: IconButton( - onPressed: () { - chatStore.assetsStore.recentEmotes.clear(); - chatStore.updateNotification( - 'Recent emotes cleared', - ); - }, - icon: const Icon( - Icons.delete_outline_rounded, + // Lay the content out at its full target height regardless of + // the animated container's current height, then clip — the + // Column would otherwise overflow (RenderFlex error) as the + // height tweens toward zero and can no longer fit the Divider. + child: OverflowBox( + minHeight: 0, + maxHeight: menuHeight, + alignment: Alignment.topCenter, + child: Column( + children: [ + const Divider(), + Expanded( + child: FrostyPageView( + headers: [ + 'Recent', + if (chatStore.settings.showTwitchEmotes) + 'Twitch', + if (chatStore.settings.show7TVEmotes) '7TV', + if (chatStore.settings.showBTTVEmotes) 'BTTV', + if (chatStore.settings.showFFZEmotes) 'FFZ', + ], + tabActions: { + if (chatStore + .assetsStore + .recentEmotes + .isNotEmpty) + 0: IconButton( + onPressed: () { + chatStore.assetsStore.recentEmotes.clear(); + chatStore.updateNotification( + 'Recent emotes cleared', + ); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Clear recent emotes', + iconSize: 20, + ), + }, + children: [ + RecentEmotesPanel(chatStore: chatStore), + if (chatStore.settings.showTwitchEmotes) + EmoteMenuPanel( + chatStore: chatStore, + twitchEmotes: chatStore + .assetsStore + .userEmoteSectionToEmotes, + ), + ...[ + 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, + emotes: emotes, ), - tooltip: 'Clear recent emotes', - iconSize: 20, ), - }, - children: [ - RecentEmotesPanel(chatStore: chatStore), - if (chatStore.settings.showTwitchEmotes) - EmoteMenuPanel( - chatStore: chatStore, - twitchEmotes: chatStore - .assetsStore - .userEmoteSectionToEmotes, - ), - ...[ - 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, - emotes: emotes, - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ) : null, diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index d0f9ed6e..750315b8 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -651,7 +651,7 @@ abstract class ChatStoreBase with Store { }, onBadgeError: (error) { debugPrint(error.toString()); - return []; + return {}; }, showTwitchEmotes: settings.showTwitchEmotes, showTwitchBadges: settings.showTwitchBadges, @@ -798,9 +798,12 @@ abstract class ChatStoreBase with Store { return []; } - List onBadgeError(dynamic error) { + // Returns a Map, not a List: this is attached via `.catchError` to the + // `getBadgesChannel` future (a Future>), so the + // handler must return that type or Dart throws at runtime. + Map onBadgeError(dynamic error) { debugPrint(error.toString()); - return []; + return {}; } final baseAssets = assetsStore.assetsFuture( diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index d75a2718..f74d58ce 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -340,11 +340,10 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it. if (Platform.isAndroid) { _disposeAndroidAutoPipReaction = autorun((_) async { - if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) { - pip.setAutoPipMode(); - } else { - pip.setAutoPipMode(autoEnter: false); - } + // setAutoPipMode is only implemented on Android S+ — invoking it on + // older versions throws a PlatformException, so skip when unavailable. + if (!await SimplePip.isAutoPipAvailable) return; + pip.setAutoPipMode(autoEnter: settingsStore.showVideo); }); } diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index f37a39eb..56b1cf99 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -622,6 +622,24 @@ void main() { expect(msg.localEmotes, isNotNull); expect(msg.localEmotes!.containsKey('Kappa'), isTrue); }); + + test('returns Command.none for a truncated line without a command', () { + // A malformed/truncated line (e.g. from the recent-messages history API) + // whose message section is a single token used to throw a RangeError on + // splitMessage[1]. It must now parse safely as a none command. + final msg = IRCMessage.fromString('@historical=1 :justaprefix'); + + expect(msg.command, Command.none); + expect(msg.tags['historical'], '1'); + }); + + test('returns Command.none for a line with no separating space', () { + // No space means there are no tags to slice and no body to parse; must + // not throw on the substring/split calls. + final msg = IRCMessage.fromString('@onlytags'); + + expect(msg.command, Command.none); + }); }); group('USERSTATE', () { From b8648426f8602e7c43ed06ebd84f2f012cd1f49e Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Thu, 2 Jul 2026 17:23:26 -0400 Subject: [PATCH 15/67] fix(video): keep native player alive across rotation (#575) Wrap the native player in a GlobalKey so an orientation change re-parents the existing platform view instead of tearing it down and reloading the stream. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/screens/channel/channel.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index d1469988..c66dae36 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -312,7 +312,14 @@ class _VideoChatState extends State final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, child: _videoStore is NativeVideoStore - ? NativeVideo(nativeVideoStore: _videoStore) + ? NativeVideo( + // GlobalKey so the native platform view is *reparented* (not + // rebuilt) across the portrait↔landscape layout swap — without + // it, rotating destroys and recreates the player, forcing a + // reload. Mirrors the WebView Video below. + key: _videoKey, + nativeVideoStore: _videoStore, + ) : Video( key: _videoKey, videoStore: _videoStore as VideoStore, From 2511ae93fd83139eff82234b3a3ee4617c8e254b Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Thu, 2 Jul 2026 17:43:37 -0400 Subject: [PATCH 16/67] feat(chat): moderator actions (delete / timeout / ban / unban) (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable ModerationActions widget shown to channel moderators (and the broadcaster) from both the message long-press menu and the user panel. Supports delete message, timeout (10 min), ban, and remove ban/timeout — the recovery action appears only when the target is currently banned or timed out (tracked via CLEARCHAT). Surfaces Twitch's own error reason when an action is rejected, and forces an immediate chat re-render so a deleted/banned message reflects instantly. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/apis/twitch_api.dart | 98 +++++++++++ .../chat/stores/banned_user_tracker.dart | 31 ++++ .../channel/chat/stores/chat_store.dart | 39 +++++ .../channel/chat/widgets/chat_message.dart | 17 +- .../channel/chat/widgets/chat_user_modal.dart | 8 + .../chat/widgets/moderation_actions.dart | 162 ++++++++++++++++++ lib/screens/settings/stores/auth_store.dart | 2 +- lib/screens/settings/stores/user_store.dart | 72 +++++++- lib/widgets/user_actions_modal.dart | 6 + test/apis/twitch_api_test.dart | 133 ++++++++++++++ .../chat/banned_user_tracker_test.dart | 49 ++++++ 11 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 lib/screens/channel/chat/stores/banned_user_tracker.dart create mode 100644 lib/screens/channel/chat/widgets/moderation_actions.dart create mode 100644 test/screens/channel/chat/banned_user_tracker_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 1028c216..9cc5dda5 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -465,4 +465,102 @@ class TwitchApi extends BaseApiClient { return data['messages'] as JsonList; } + + /// Returns a list of broadcaster IDs for channels the authenticated user moderates. + Future> getModeratedChannels({required String id}) async { + try { + final data = await get( + '/moderation/channels', + // Default page size is 20; request the max to avoid hiding mod + // actions for users who moderate many channels. + queryParameters: {'user_id': id, 'first': '100'}, + ); + + final channels = data['data'] as JsonList; + return channels + .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) + .whereType() + .toList(); + } on ApiException catch (e) { + // Existing users may not have re-authenticated with the new scope yet. + // Treat this as non-fatal and hide moderation actions. + debugPrint('Failed to get moderated channels: $e'); + return []; + } + } + + /// Deletes a specific chat message. + Future deleteChatMessage({ + required String broadcasterId, + required String moderatorId, + required String messageId, + }) async { + // Let ApiException propagate so the caller can surface Twitch's reason + // (e.g. you can't delete the broadcaster's or another moderator's message) + // instead of a generic failure. + await delete( + '/moderation/chat', + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + 'message_id': messageId, + }, + ); + return true; + } + + /// Bans or times out a user from a channel. + /// + /// The optional `duration` in seconds will timeout the user. If omitted, the user is banned. + /// The optional `reason` will be displayed to the banned user and other moderators. + Future banUser({ + required String broadcasterId, + required String moderatorId, + required String userIdToBan, + int? duration, + String? reason, + }) async { + final requestBody = { + 'data': { + 'user_id': userIdToBan, + 'duration': ?duration, + 'reason': ?reason, + }, + }; + + // Let ApiException propagate so the caller can surface Twitch's reason + // (e.g. the target is a moderator and can't be banned) instead of a + // generic failure. + final response = await post( + '/moderation/bans', + data: requestBody, + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + }, + ); + final data = response['data'] as JsonList; + return data.isNotEmpty; + } + + /// Removes a ban or timeout for a user in a channel. + /// + /// A timeout is a time-limited ban, so this lifts both. Lets ApiException + /// propagate (e.g. the user isn't currently banned) so the caller can show + /// Twitch's reason. + Future unbanUser({ + required String broadcasterId, + required String moderatorId, + required String userId, + }) async { + await delete( + '/moderation/bans', + queryParameters: { + 'broadcaster_id': broadcasterId, + 'moderator_id': moderatorId, + 'user_id': userId, + }, + ); + return true; + } } diff --git a/lib/screens/channel/chat/stores/banned_user_tracker.dart b/lib/screens/channel/chat/stores/banned_user_tracker.dart new file mode 100644 index 00000000..83a929b2 --- /dev/null +++ b/lib/screens/channel/chat/stores/banned_user_tracker.dart @@ -0,0 +1,31 @@ +/// Tracks which users are currently banned or timed out, learned from IRC +/// CLEARCHAT events, so the chat can offer an "unban / remove timeout" action +/// only when it's actually applicable. +/// +/// Keyed by Twitch user id. A value of null means a permanent ban; a non-null +/// [DateTime] is when a timeout expires. Pure logic (clock passed in) so it can +/// be unit-tested without the chat store. +class BannedUserTracker { + final _expiries = {}; + + /// Record a permanent ban (CLEARCHAT without a ban-duration). + void recordBan(String userId) => _expiries[userId] = null; + + /// Record a timeout (CLEARCHAT with a ban-duration), expiring at [now] + [duration]. + void recordTimeout(String userId, Duration duration, DateTime now) => + _expiries[userId] = now.add(duration); + + /// Forget a user (e.g. after a successful unban). + void clear(String userId) => _expiries.remove(userId); + + /// Whether [userId] is currently banned or within an unexpired timeout. + /// Expired timeouts are pruned on read. + bool isBannedOrTimedOut(String userId, DateTime now) { + if (!_expiries.containsKey(userId)) return false; + final expiry = _expiries[userId]; + if (expiry == null) return true; // permanent ban + if (now.isBefore(expiry)) return true; // timeout still active + _expiries.remove(userId); // expired — clean up + return false; + } +} diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 750315b8..8496a6c5 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -10,6 +10,7 @@ import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/events.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; +import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -254,6 +255,17 @@ abstract class ChatStoreBase with Store { @readonly var _autoScroll = true; + /// Tracks users banned/timed-out via CLEARCHAT so the moderation menu only + /// offers an unban action when it applies. + final _bannedUserTracker = BannedUserTracker(); + + /// Whether [userId] is currently banned or within an unexpired timeout + /// (as observed from CLEARCHAT in this session). + bool isUserBannedOrTimedOut(String userId) => + _bannedUserTracker.isBannedOrTimedOut(userId, DateTime.now()); + + /// Forget a user's ban state after a successful unban so the action hides. + void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); @readonly var _inputText = ''; @@ -674,11 +686,35 @@ abstract class ChatStoreBase with Store { messageBuffer.add(parsedIRCMessage); break; case Command.clearChat: + // Track the banned/timed-out user so the chat can offer an unban + // action only when applicable. target-user-id is absent for a + // whole-chat clear. + final clearedUserId = parsedIRCMessage.tags['target-user-id']; + if (clearedUserId != null) { + final banDuration = parsedIRCMessage.tags['ban-duration']; + final seconds = banDuration != null + ? int.tryParse(banDuration) + : null; + if (seconds != null) { + _bannedUserTracker.recordTimeout( + clearedUserId, + Duration(seconds: seconds), + DateTime.now(), + ); + } else { + _bannedUserTracker.recordBan(clearedUserId); + } + } IRCMessage.clearChat( messages: _messages, bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // clearChat marks messages in place, which doesn't notify the + // observable list; reassign so renderMessages recomputes and the + // ban/timeout strike-through shows without waiting for the next + // message. + _messages = _messages.toList().asObservable(); break; case Command.clearMessage: IRCMessage.clearMessage( @@ -686,6 +722,9 @@ abstract class ChatStoreBase with Store { bufferedMessages: messageBuffer, ircMessage: parsedIRCMessage, ); + // Same as clearChat: force a re-render so the deleted message + // updates immediately rather than on the next incoming message. + _messages = _messages.toList().asObservable(); break; case Command.roomState: chatDetailsStore.roomState = chatDetailsStore.roomState diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 87e313d4..3bc8c289 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -8,6 +8,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; +import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; @@ -106,8 +107,14 @@ class ChatMessage extends StatelessWidget { void onLongPressMessage(BuildContext context, TextStyle defaultTextStyle) { HapticFeedback.lightImpact(); + // Allow the full menu for live messages and for ones marked deleted/cleared + // by a moderation action — those keep their tags (user-id, id), so a + // moderator can still act on them (e.g. remove a timeout). Everything else + // (notices, system messages) just copies. if (ircMessage.command != Command.privateMessage && - ircMessage.command != Command.userState) { + ircMessage.command != Command.userState && + ircMessage.command != Command.clearChat && + ircMessage.command != Command.clearMessage) { copyMessage(); return; } @@ -199,6 +206,14 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.reply), title: const Text('Reply to message'), ), + if (ircMessage.tags['user-id'] != null) + ModerationActions( + chatStore: chatStore, + targetUserId: ircMessage.tags['user-id']!, + targetName: + ircMessage.tags['display-name'] ?? ircMessage.user ?? '', + messageId: ircMessage.tags['id'], + ), ], ), ); diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 07c975a5..6cb2e55a 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; +import 'package:frosty/screens/channel/chat/widgets/moderation_actions.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; @@ -80,6 +81,13 @@ class _ChatUserModalState extends State { name: name, userLogin: widget.username, userId: widget.userId, + // Moderator actions for this user (no messageId → no + // Delete); renders nothing for non-moderators. + moderationActions: ModerationActions( + chatStore: widget.chatStore, + targetUserId: widget.userId, + targetName: name, + ), ), ), icon: Icon(Icons.adaptive.more_rounded), diff --git a/lib/screens/channel/chat/widgets/moderation_actions.dart b/lib/screens/channel/chat/widgets/moderation_actions.dart new file mode 100644 index 00000000..2062be4b --- /dev/null +++ b/lib/screens/channel/chat/widgets/moderation_actions.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; + +/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], +/// reused by the message long-press menu and the user panel. +/// +/// Renders nothing unless the viewer can moderate the channel. [messageId] +/// enables the per-message Delete action; omit it (e.g. the user panel, which +/// isn't tied to one message) to hide Delete. The unban action only appears +/// when the user is currently banned/timed-out (seen via CLEARCHAT). +class ModerationActions extends StatelessWidget { + final ChatStore chatStore; + final String targetUserId; + + /// Display name used in confirmation notifications. + final String targetName; + + /// The message to delete; null hides the Delete action. + final String? messageId; + + const ModerationActions({ + super.key, + required this.chatStore, + required this.targetUserId, + required this.targetName, + this.messageId, + }); + + @override + Widget build(BuildContext context) { + if (!chatStore.auth.user.canModerate(chatStore.channelId)) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + // Compact one-row punitive actions, ordered least- to most-destructive. + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Row( + children: [ + if (messageId != null) ...[ + Expanded( + child: _button( + Icons.delete_outline, + 'Delete', + () => _run(context, _delete), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: _button( + Icons.timer_outlined, + 'Timeout 10min', + () => _run(context, _timeout), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _button( + Icons.block, + 'Ban', + () => _run(context, _ban), + ), + ), + ], + ), + ), + // Recovery action, separate from the punitive row to avoid mistaps and + // only shown when the user is actually banned/timed-out. + if (chatStore.isUserBannedOrTimedOut(targetUserId)) + ListTile( + onTap: () => _run(context, _unban), + leading: const Icon(Icons.lock_open_rounded), + title: const Text('Remove ban or timeout'), + ), + ], + ); + } + + /// Compact icon-over-label button. + Widget _button(IconData icon, String label, VoidCallback onTap) { + return OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ], + ), + ); + } + + /// Closes the sheet, then runs [action], surfacing Twitch's reason on failure. + Future _run( + BuildContext context, + Future Function() action, + ) async { + Navigator.pop(context); + try { + await action(); + } on ApiException catch (e) { + chatStore.updateNotification(e.message); + } + } + + Future _delete() async { + final success = await chatStore.auth.user.deleteMessage( + broadcasterId: chatStore.channelId, + messageId: messageId!, + ); + chatStore.updateNotification( + success ? 'Message deleted' : 'Failed to delete message', + ); + } + + Future _timeout() async { + final success = await chatStore.auth.user.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: targetUserId, + duration: 600, // 10 minutes + ); + chatStore.updateNotification( + success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user', + ); + } + + Future _ban() async { + final success = await chatStore.auth.user.banOrTimeoutUser( + broadcasterId: chatStore.channelId, + userIdToBan: targetUserId, + ); + chatStore.updateNotification( + success ? '$targetName banned.' : 'Failed to ban user', + ); + } + + Future _unban() async { + final success = await chatStore.auth.user.unbanUser( + broadcasterId: chatStore.channelId, + userIdToUnban: targetUserId, + ); + if (success) chatStore.clearUserBanState(targetUserId); + chatStore.updateNotification( + success + ? 'Removed ban/timeout for $targetName.' + : 'Failed to remove ban or timeout', + ); + } +} diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 18548635..88ec669a 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -171,7 +171,7 @@ abstract class AuthBase with Store { 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', 'scope': - 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color', + 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users', 'force_verify': 'true', }, ), diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 39153fe1..1322bf5d 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -17,6 +17,10 @@ abstract class UserStoreBase with Store { @readonly var _blockedUsers = ObservableList(); + /// The list of channel IDs the user moderates. + @readonly + var _moderatedChannels = ObservableList(); + ReactionDisposer? _disposeReaction; UserStoreBase({required this.twitchApi}); @@ -26,12 +30,15 @@ abstract class UserStoreBase with Store { // Get and update the current user's info. _details = await twitchApi.getUserInfo(); - // Get and update the current user's list of blocked users. + // Get and update non-critical user info. // Don't use await because having a huge list of blocked users will block the UI. if (_details?.id != null) { twitchApi .getUserBlockedList(id: _details!.id) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); + twitchApi + .getModeratedChannels(id: _details!.id) + .then((channels) => _moderatedChannels = channels.asObservable()); } _disposeReaction = autorun( @@ -61,10 +68,73 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); + bool isModerator(String channelId) { + return _moderatedChannels.contains(channelId); + } + + /// Whether the logged-in user can moderate [channelId] — either a moderator + /// of that channel or its broadcaster (the broadcaster isn't in their own + /// moderator list but can still use the moderation endpoints). + bool canModerate(String channelId) => + _details?.id == channelId || isModerator(channelId); + + @action + Future deleteMessage({ + required String broadcasterId, + required String messageId, + }) async { + // Need the moderator ID and confirmed mod status for this channel. + if (_details?.id == null || !canModerate(broadcasterId)) { + return false; + } + return twitchApi.deleteChatMessage( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + messageId: messageId, + ); + } + + @action + Future banOrTimeoutUser({ + required String broadcasterId, + required String userIdToBan, + int? duration, + String? reason, + }) async { + // Need the moderator ID and confirmed mod status for this channel. + if (_details?.id == null || !canModerate(broadcasterId)) { + return false; + } + return twitchApi.banUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userIdToBan: userIdToBan, + duration: duration, + reason: reason, + ); + } + + /// Removes a ban or timeout for [userIdToUnban] in the channel. + @action + Future unbanUser({ + required String broadcasterId, + required String userIdToUnban, + }) async { + if (_details?.id == null || !canModerate(broadcasterId)) { + return false; + } + return twitchApi.unbanUser( + broadcasterId: broadcasterId, + moderatorId: _details!.id, + userId: userIdToUnban, + ); + } + @action void dispose() { _details = null; _blockedUsers.clear(); + _moderatedChannels.clear(); if (_disposeReaction != null) _disposeReaction!(); } } diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index 099bd1c2..d86c75ae 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -14,6 +14,10 @@ class UserActionsModal extends StatelessWidget { final bool showPinOption; final bool? isPinned; + /// Optional moderation actions (shown at the top) for chat contexts. Kept as + /// an injected widget so this generic modal stays chat-agnostic. + final Widget? moderationActions; + const UserActionsModal({ super.key, required this.authStore, @@ -22,6 +26,7 @@ class UserActionsModal extends StatelessWidget { required this.userId, this.showPinOption = false, this.isPinned, + this.moderationActions, }); @override @@ -30,6 +35,7 @@ class UserActionsModal extends StatelessWidget { primary: false, shrinkWrap: true, children: [ + ?moderationActions, if (showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 5b5df81a..938c6bb9 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -389,4 +389,137 @@ void main() { expect(emotes[2].type, EmoteType.twitchChannel); }); }); + + group('banUser', () { + test('returns true on success', () async { + dioAdapter.onPost( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(200, { + 'data': [ + {'user_id': '3'}, + ], + }), + data: { + 'data': {'user_id': '3'}, + }, + queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, + ); + + final result = await api.banUser( + broadcasterId: '1', + moderatorId: '2', + userIdToBan: '3', + ); + + expect(result, isTrue); + }); + + test('propagates the Twitch message when the target cannot be banned ' + '(e.g. another moderator)', () async { + dioAdapter.onPost( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(400, { + 'message': 'The user specified in the user_id field is a moderator.', + }), + data: { + 'data': {'user_id': '3'}, + }, + queryParameters: {'broadcaster_id': '1', 'moderator_id': '2'}, + ); + + expect( + () => api.banUser( + broadcasterId: '1', + moderatorId: '2', + userIdToBan: '3', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'The user specified in the user_id field is a moderator.', + ), + ), + ); + }); + }); + + group('deleteChatMessage', () { + test('propagates the Twitch message when the message cannot be deleted ' + "(e.g. another moderator's or the broadcaster's)", () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/chat', + (server) => server.reply(400, { + 'message': "You cannot delete the broadcaster's messages.", + }), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'message_id': 'm', + }, + ); + + expect( + () => api.deleteChatMessage( + broadcasterId: '1', + moderatorId: '2', + messageId: 'm', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + "You cannot delete the broadcaster's messages.", + ), + ), + ); + }); + }); + + group('unbanUser', () { + test('returns true on success (removes a ban or timeout)', () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(204, null), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'user_id': '3', + }, + ); + + final result = await api.unbanUser( + broadcasterId: '1', + moderatorId: '2', + userId: '3', + ); + + expect(result, isTrue); + }); + + test('propagates the Twitch message when the user is not banned', () async { + dioAdapter.onDelete( + 'https://api.twitch.tv/helix/moderation/bans', + (server) => server.reply(400, { + 'message': 'The user in the user_id query parameter is not banned.', + }), + queryParameters: { + 'broadcaster_id': '1', + 'moderator_id': '2', + 'user_id': '3', + }, + ); + + expect( + () => api.unbanUser(broadcasterId: '1', moderatorId: '2', userId: '3'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'The user in the user_id query parameter is not banned.', + ), + ), + ); + }); + }); } diff --git a/test/screens/channel/chat/banned_user_tracker_test.dart b/test/screens/channel/chat/banned_user_tracker_test.dart new file mode 100644 index 00000000..32093682 --- /dev/null +++ b/test/screens/channel/chat/banned_user_tracker_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; + +void main() { + late BannedUserTracker tracker; + final now = DateTime(2026, 6, 28, 12); + setUp(() => tracker = BannedUserTracker()); + + test('an untracked user is not banned', () { + expect(tracker.isBannedOrTimedOut('1', now), isFalse); + }); + + test('a banned user stays banned (no expiry)', () { + tracker.recordBan('1'); + expect(tracker.isBannedOrTimedOut('1', now), isTrue); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(days: 365))), + isTrue, + ); + }); + + test('a timed-out user is banned until the timeout expires', () { + tracker.recordTimeout('1', const Duration(seconds: 60), now); + expect(tracker.isBannedOrTimedOut('1', now), isTrue); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 59))), + isTrue, + ); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 61))), + isFalse, + ); + }); + + test('clear lifts a ban', () { + tracker.recordBan('1'); + tracker.clear('1'); + expect(tracker.isBannedOrTimedOut('1', now), isFalse); + }); + + test('a later permanent ban overrides an earlier timeout', () { + tracker.recordTimeout('1', const Duration(seconds: 10), now); + tracker.recordBan('1'); + expect( + tracker.isBannedOrTimedOut('1', now.add(const Duration(seconds: 30))), + isTrue, + ); + }); +} From 66932c5d0b6e8d3f594285cef44d4abc327d695d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:15:42 -0400 Subject: [PATCH 17/67] fix(chat): keep long-press from missing in fast-moving chat (#579) Freeze the message list on a stationary touch-and-hold (pointer-counted, touch-slop aware) so an incoming message can't shift the row out from under a long-press, while still allowing scroll to cancel the freeze. Co-authored-by: Francis Lavoie Co-authored-by: Claude Opus 4.8 (1M context) --- lib/screens/channel/chat/chat.dart | 128 +++++++++++------- .../chat/stores/chat_interaction_pause.dart | 54 ++++++++ .../channel/chat/stores/chat_store.dart | 54 +++++--- .../channel/chat/stores/chat_tabs_store.dart | 34 ++++- .../chat/chat_interaction_pause_test.dart | 86 ++++++++++++ 5 files changed, 282 insertions(+), 74 deletions(-) create mode 100644 lib/screens/channel/chat/stores/chat_interaction_pause.dart create mode 100644 test/screens/channel/chat/chat_interaction_pause_test.dart diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 70d14750..80ceda50 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -86,25 +86,59 @@ class Chat extends StatelessWidget { ? chatTabsStore!.mergedScrollController : chatStore.scrollController; - return FrostyScrollbar( - controller: scrollController, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top, - bottom: - chatStore.bottomBarHeight + bottomPadding, - ), - child: Observer( - builder: (context) { - return _isMerged - ? _buildMergedList( - scrollController, - bottomPadding, - ) - : _buildNormalList( - scrollController, - bottomPadding, - ); - }, + // Freeze the message list while a finger rests on a + // message so a long-press target can't scroll out + // from under it (which would dispose its recognizer + // mid-press). The store resumes flowing as soon as + // the pointer moves like a scroll, so scrolling is + // never interrupted — only a stationary hold pauses. + void onDown(PointerDownEvent e) => _isMerged + ? chatTabsStore!.onMessageListPointerDown( + e.pointer, + e.position, + ) + : chatStore.onMessageListPointerDown( + e.pointer, + e.position, + ); + void onMove(PointerMoveEvent e) => _isMerged + ? chatTabsStore!.onMessageListPointerMove( + e.pointer, + e.position, + ) + : chatStore.onMessageListPointerMove( + e.pointer, + e.position, + ); + void onUp(int pointer) => _isMerged + ? chatTabsStore!.onMessageListPointerUp(pointer) + : chatStore.onMessageListPointerUp(pointer); + + return Listener( + onPointerDown: onDown, + onPointerMove: onMove, + onPointerUp: (e) => onUp(e.pointer), + onPointerCancel: (e) => onUp(e.pointer), + child: FrostyScrollbar( + controller: scrollController, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + bottom: + chatStore.bottomBarHeight + bottomPadding, + ), + child: Observer( + builder: (context) { + return _isMerged + ? _buildMergedList( + scrollController, + bottomPadding, + ) + : _buildNormalList( + scrollController, + bottomPadding, + ); + }, + ), ), ); }, @@ -145,19 +179,25 @@ class Chat extends StatelessWidget { return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only( - bottom: chatStore.bottomBarHeight + bottomPadding, - ), + EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), ), addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages[ - chatStore.renderMessages.length - 1 - index], - chatStore: chatStore, - ), + itemBuilder: (context, index) { + final ircMessage = chatStore + .renderMessages[chatStore.renderMessages.length - 1 - index]; + // Key by message identity so the list reconciles by message rather + // than by slot. This keeps each message's element (and its gesture + // recognizers) bound to the same message when new messages arrive + // mid-interaction, fixing long-press landing on the wrong message. + return ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: chatStore, + ); + }, ); } @@ -166,25 +206,22 @@ class Chat extends StatelessWidget { double bottomPadding, ) { final mergedMessages = chatTabsStore!.mergedMessages; - final channelIdToUserTwitch = - chatTabsStore!.mergedChannelIdToUserTwitch; + final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; final currentChannelId = chatTabsStore!.activeTab.channelId; return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( - EdgeInsets.only( - bottom: chatStore.bottomBarHeight + bottomPadding, - ), + EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding), ), addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, itemBuilder: (context, index) { - final merged = - mergedMessages[mergedMessages.length - 1 - index]; + final merged = mergedMessages[mergedMessages.length - 1 - index]; return ChatMessage( + key: ObjectKey(merged.ircMessage), ircMessage: merged.ircMessage, chatStore: merged.chatStore, inputChatStore: chatStore, @@ -218,8 +255,7 @@ class Chat extends StatelessWidget { right: 4, bottom: chatStore.bottomBarHeight + - (chatStore.assetsStore.showEmoteMenu || - isHorizontalLandscape + (chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape ? 0 : MediaQuery.of(context).padding.bottom), ), @@ -243,17 +279,13 @@ class Chat extends StatelessWidget { ? null : ElevatedButton.icon( onPressed: onResume, - icon: const Icon( - Icons.arrow_downward_rounded, - ), + icon: const Icon(Icons.arrow_downward_rounded), label: Text( bufferCount > 0 ? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}' : 'Resume scroll', style: const TextStyle( - fontFeatures: [ - FontFeature.tabularFigures(), - ], + fontFeatures: [FontFeature.tabularFigures()], ), ), ), @@ -292,17 +324,13 @@ class Chat extends StatelessWidget { child: FrostyPageView( headers: [ 'Recent', - if (chatStore.settings.showTwitchEmotes) - 'Twitch', + if (chatStore.settings.showTwitchEmotes) 'Twitch', if (chatStore.settings.show7TVEmotes) '7TV', if (chatStore.settings.showBTTVEmotes) 'BTTV', if (chatStore.settings.showFFZEmotes) 'FFZ', ], tabActions: { - if (chatStore - .assetsStore - .recentEmotes - .isNotEmpty) + if (chatStore.assetsStore.recentEmotes.isNotEmpty) 0: IconButton( onPressed: () { chatStore.assetsStore.recentEmotes.clear(); @@ -310,9 +338,7 @@ class Chat extends StatelessWidget { 'Recent emotes cleared', ); }, - icon: const Icon( - Icons.delete_outline_rounded, - ), + icon: const Icon(Icons.delete_outline_rounded), tooltip: 'Clear recent emotes', iconSize: 20, ), diff --git a/lib/screens/channel/chat/stores/chat_interaction_pause.dart b/lib/screens/channel/chat/stores/chat_interaction_pause.dart new file mode 100644 index 00000000..0d3456a9 --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_interaction_pause.dart @@ -0,0 +1,54 @@ +import 'package:flutter/gestures.dart'; + +/// Decides whether the chat message list should hold buffered messages back +/// because the user is *holding* a message (a long-press candidate). +/// +/// Flushing buffered messages grows the reverse list and pushes the pressed +/// message up and out of the build range, which disposes its gesture recognizer +/// mid-press and cancels the long-press. Freezing the list while a finger rests +/// on a message keeps it under the finger so the press lands. +/// +/// Crucially, the freeze applies *only to a stationary finger*. The moment a +/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat +/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes +/// scrolling feel like it gets cancelled. This is the same threshold the +/// long-press recognizer uses to tell a hold from a drag. +/// +/// Tracks pointers individually so multi-touch resolves correctly: the chat +/// only resumes once every stationary pointer has lifted or started moving. +class ChatInteractionPause { + /// Down position of each active pointer, by pointer id. + final _origins = {}; + + /// Pointers that have moved past the slop this gesture — i.e. are scrolling. + /// Once a pointer is here it stays until it lifts, so a drag never flips back + /// to a hold mid-gesture. + final _scrolling = {}; + + /// Whether any pointer is down and still stationary (a long-press candidate). + bool get isPaused => _origins.length > _scrolling.length; + + /// Register a pointer touching down at [position]. + void pointerDown(int pointer, Offset position) { + _origins[pointer] = position; + } + + /// Update a pointer's position; flags it as scrolling once it passes the slop. + void pointerMove(int pointer, Offset position) { + final origin = _origins[pointer]; + if (origin == null) return; + if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer); + } + + /// Register a pointer lifting off (or being cancelled). + void pointerUp(int pointer) { + _origins.remove(pointer); + _scrolling.remove(pointer); + } + + /// Force-clear all active pointers (e.g. on dispose or tab switch). + void reset() { + _origins.clear(); + _scrolling.clear(); + } +} diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 8496a6c5..1b500b72 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -12,6 +12,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; @@ -98,11 +99,11 @@ abstract class ChatStoreBase with Store { return parts.length >= 3 && _delayBypassCommands.contains(parts[2]); } - Duration get _chatDelay => Duration( - milliseconds: (settings.effectiveChatDelay * 1000).round(), - ); + Duration get _chatDelay => + Duration(milliseconds: (settings.effectiveChatDelay * 1000).round()); - bool get _hasActiveChatDelay => settings.showVideo && _chatDelay > Duration.zero; + bool get _hasActiveChatDelay => + settings.showVideo && _chatDelay > Duration.zero; final TwitchApi twitchApi; @@ -266,6 +267,21 @@ abstract class ChatStoreBase with Store { /// Forget a user's ban state after a successful unban so the action hides. void clearUserBanState(String userId) => _bannedUserTracker.clear(userId); + + /// Freezes the rendered list while the user is touching it, so a long-press + /// target can't scroll out from under the finger and get its gesture + /// recognizer disposed mid-press. Plain field (not observable): it only gates + /// the periodic [addMessages] flush, which reads it each 200ms tick. + final _interactionPause = ChatInteractionPause(); + + /// Hooks for the message list to report touch start/move/end (see [Chat]). + void onMessageListPointerDown(int pointer, Offset position) => + _interactionPause.pointerDown(pointer, position); + void onMessageListPointerMove(int pointer, Offset position) => + _interactionPause.pointerMove(pointer, position); + void onMessageListPointerUp(int pointer) => + _interactionPause.pointerUp(pointer); + @readonly var _inputText = ''; @@ -375,16 +391,12 @@ abstract class ChatStoreBase with Store { // Normalize muted words once per settings change instead of per message // — the filter runs in the IRC hot path (50+ msgs/s in busy channels). reactions.add( - reaction( - (_) => settings.mutedWords.toList(), - (List words) { - _normalizedMutedWords = words - .map((word) => word.trim().toLowerCase()) - .where((word) => word.isNotEmpty) - .toList(); - }, - fireImmediately: true, - ), + reaction((_) => settings.mutedWords.toList(), (List words) { + _normalizedMutedWords = words + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(); + }, fireImmediately: true), ); reactions.add( @@ -616,8 +628,7 @@ abstract class ChatStoreBase with Store { final msg = parsedIRCMessage.message!.toLowerCase(); final words = settings.matchWholeWord ? msg.split(' ') : null; final muted = _normalizedMutedWords.any( - (word) => - words != null ? words.contains(word) : msg.contains(word), + (word) => words != null ? words.contains(word) : msg.contains(word), ); if (muted) continue; } @@ -1198,7 +1209,11 @@ abstract class ChatStoreBase with Store { void addMessages() { _flushDueDelayedCallbacks(); - if (!_autoScroll || messageBuffer.isEmpty) return; + // Hold messages back while the user is touching the list so the pressed + // message stays put (and alive) for the duration of a long-press. + if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) { + return; + } _messages.addAll(messageBuffer); messageBuffer.clear(); @@ -1352,7 +1367,8 @@ abstract class ChatStoreBase with Store { : null; if (toSend != null) { // Prefer the server id; fall back to any id already set, then a local one. - toSend!.tags['id'] = serverMessageId ?? + toSend!.tags['id'] = + serverMessageId ?? toSend!.tags['id'] ?? 'local-${DateTime.now().millisecondsSinceEpoch}'; messageBuffer.add(toSend!); @@ -1506,6 +1522,8 @@ abstract class ChatStoreBase with Store { void dispose() { _shouldDisconnect = true; + _interactionPause.reset(); + _clearPendingDelayedCallbacks(); _messageBufferTimer?.cancel(); diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index c5c2819d..ee095442 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -9,6 +9,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -151,6 +152,19 @@ abstract class ChatTabsStoreBase with Store { /// Timer that drives merged message rendering at a fixed cadence. Timer? _mergedRenderTimer; + /// Freezes the rendered merged list while the user is touching it, so a + /// long-press target can't scroll out from under the finger. Gates the + /// periodic [_refreshMergedMessages]; see [ChatInteractionPause]. + final _interactionPause = ChatInteractionPause(); + + /// Hooks for the merged message list to report touch start/move/end (see [Chat]). + void onMessageListPointerDown(int pointer, Offset position) => + _interactionPause.pointerDown(pointer, position); + void onMessageListPointerMove(int pointer, Offset position) => + _interactionPause.pointerMove(pointer, position); + void onMessageListPointerUp(int pointer) => + _interactionPause.pointerUp(pointer); + /// Frozen snapshot of merged messages when user scrolls up. /// Prevents new messages from causing scroll jumps while reading. List? _mergedSnapshot; @@ -188,8 +202,9 @@ abstract class ChatTabsStoreBase with Store { int? tailCount, }) { final msgs = store.messages; - final start = - tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0; + final start = tailCount != null && msgs.length > tailCount + ? msgs.length - tailCount + : 0; for (var i = start; i < msgs.length; i++) { final id = msgs[i].tags['id']; if (id != null && !seenIds.add(id)) continue; @@ -225,7 +240,12 @@ abstract class ChatTabsStoreBase with Store { final seenIds = {}; for (final tab in _tabs) { if (tab.chatStore == null) continue; - _collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit); + _collectMessages( + tab.chatStore!, + recent, + seenIds, + tailCount: _mergedRenderLimit, + ); } recent.sort(_compareByTimestamp); if (recent.length > _mergedRenderLimit) { @@ -249,7 +269,9 @@ abstract class ChatTabsStoreBase with Store { /// Refreshes the rendered merged messages. Called by the render timer. @action void _refreshMergedMessages() { - if (!_mergedAutoScroll) return; + // Hold the rendered list still while the user is touching it so a + // long-press target stays put for the duration of the press. + if (!_mergedAutoScroll || _interactionPause.isPaused) return; _mergedRenderedMessages = _computeRecentMergedMessages(); } @@ -260,7 +282,8 @@ abstract class ChatTabsStoreBase with Store { for (final tab in _tabs) { if (tab.chatStore != null) { total += - tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length; + tab.chatStore!.messages.length + + tab.chatStore!.messageBuffer.length; } } return total; @@ -718,6 +741,7 @@ abstract class ChatTabsStoreBase with Store { void dispose() { _mergedRenderTimer?.cancel(); _mergedRenderTimer = null; + _interactionPause.reset(); for (final tab in _tabs) { tab.chatStore?.dispose(); } diff --git a/test/screens/channel/chat/chat_interaction_pause_test.dart b/test/screens/channel/chat/chat_interaction_pause_test.dart new file mode 100644 index 00000000..f535c5a4 --- /dev/null +++ b/test/screens/channel/chat/chat_interaction_pause_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; + +void main() { + late ChatInteractionPause pause; + + setUp(() => pause = ChatInteractionPause()); + + // A move clearly past the touch slop (a scroll), and one well within it. + final beyondSlop = Offset(0, kTouchSlop + 10); + final withinSlop = Offset(0, kTouchSlop - 5); + + test('is not paused initially', () { + expect(pause.isPaused, isFalse); + }); + + test('is paused while a stationary pointer is down (long-press candidate)', () { + pause.pointerDown(1, Offset.zero); + + expect(pause.isPaused, isTrue); + }); + + test('resumes once the pointer is lifted', () { + pause.pointerDown(1, Offset.zero); + pause.pointerUp(1); + + expect(pause.isPaused, isFalse); + }); + + test('resumes when the pointer moves beyond the touch slop (scrolling)', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, beyondSlop); + + // The finger is dragging — this is a scroll, not a hold, so the chat must + // keep flowing rather than freeze. + expect(pause.isPaused, isFalse); + }); + + test('stays paused for tiny jitter within the touch slop', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, withinSlop); + + expect(pause.isPaused, isTrue); + }); + + test('stays resumed for the rest of the gesture even if the finger drifts ' + 'back within slop', () { + pause.pointerDown(1, Offset.zero); + pause.pointerMove(1, beyondSlop); + pause.pointerMove(1, withinSlop); + + // Once a gesture is recognized as a scroll it should not flip back to a + // hold mid-drag. + expect(pause.isPaused, isFalse); + }); + + test('stays paused until every stationary pointer lifts (multi-touch)', () { + pause.pointerDown(1, Offset.zero); + pause.pointerDown(2, Offset.zero); + pause.pointerUp(1); + + expect(pause.isPaused, isTrue); + + pause.pointerUp(2); + expect(pause.isPaused, isFalse); + }); + + test('a stray pointer-up never leaves the gate stuck', () { + pause.pointerUp(99); + + expect(pause.isPaused, isFalse); + + pause.pointerDown(1, Offset.zero); + expect(pause.isPaused, isTrue); + }); + + test('reset clears all active pointers', () { + pause.pointerDown(1, Offset.zero); + pause.pointerDown(2, Offset.zero); + + pause.reset(); + + expect(pause.isPaused, isFalse); + }); +} From 64c748d0b634796c50b30cfc02470accf6ad7244 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:51:04 -0400 Subject: [PATCH 18/67] fix(chat): paginate moderated channels and refresh mod status on entry - getModeratedChannels now follows the `after` cursor so moderators of more than 100 channels aren't silently truncated at the first page (mirrors getUserBlockedList's throttled recursion) - add refreshModeratedChannels() and fire it on channel entry so mod status reflects grants/revokes made since login - regenerate user_store.g.dart, which also restores #576's moderation action wrappers and the _moderatedChannels atom (its generated file was never regenerated when #576 merged) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/apis/twitch_api.dart | 31 +++++-- .../channel/chat/stores/chat_store.dart | 4 + lib/screens/settings/stores/user_store.dart | 10 +++ lib/screens/settings/stores/user_store.g.dart | 90 +++++++++++++++++++ test/apis/twitch_api_test.dart | 64 +++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 9cc5dda5..60401ebe 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -466,21 +466,40 @@ class TwitchApi extends BaseApiClient { return data['messages'] as JsonList; } - /// Returns a list of broadcaster IDs for channels the authenticated user moderates. - Future> getModeratedChannels({required String id}) async { + /// Returns a list of broadcaster IDs for channels the authenticated user + /// moderates, following pagination so moderators of more than one page of + /// channels aren't silently truncated at the first 100. + Future> getModeratedChannels({ + required String id, + String? cursor, + }) async { try { + // Default page size is 20; request the max to keep the request count low. + final queryParams = {'user_id': id, 'first': '100'}; + if (cursor != null) queryParams['after'] = cursor; + final data = await get( '/moderation/channels', - // Default page size is 20; request the max to avoid hiding mod - // actions for users who moderate many channels. - queryParameters: {'user_id': id, 'first': '100'}, + queryParameters: queryParams, ); final channels = data['data'] as JsonList; - return channels + final result = channels .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) .whereType() .toList(); + + final paginationCursor = data['pagination']['cursor']; + if (paginationCursor != null && channels.isNotEmpty) { + // Throttle between pages to stay under Twitch's rate limit, matching + // getUserBlockedList above. + await Future.delayed(const Duration(milliseconds: 150)); + result.addAll( + await getModeratedChannels(id: id, cursor: paginationCursor), + ); + } + + return result; } on ApiException catch (e) { // Existing users may not have re-authenticated with the new scope yet. // Treat this as non-fatal and hide moderation actions. diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 1b500b72..d5b60e49 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -467,6 +467,10 @@ abstract class ChatStoreBase with Store { connectToChat(); } + // Refresh which channels the user moderates on entry so mod actions reflect + // status granted/revoked since login. Fire-and-forget, like blocked users. + if (auth.isLoggedIn) auth.user.refreshModeratedChannels(); + // Tell the scrollController to determine when auto-scroll should be enabled or disabled. scrollController.addListener(() { // If the scroll position is at the latest message (maximum possible), enable autoscroll. diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 1322bf5d..70de0c63 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -68,6 +68,16 @@ abstract class UserStoreBase with Store { Future refreshBlockedUsers() async => _blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id)).asObservable(); + /// Re-fetches which channels the user moderates. Mod status is otherwise only + /// loaded once at login, so call this when entering a channel to reflect mod + /// status granted or revoked since then. + @action + Future refreshModeratedChannels() async { + if (_details?.id == null) return; + _moderatedChannels = + (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); + } + bool isModerator(String channelId) { return _moderatedChannels.contains(channelId); } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index 2e5659eb..084a0bda 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -49,6 +49,26 @@ mixin _$UserStore on UserStoreBase, Store { }); } + late final _$_moderatedChannelsAtom = Atom( + name: 'UserStoreBase._moderatedChannels', + context: context, + ); + + ObservableList get moderatedChannels { + _$_moderatedChannelsAtom.reportRead(); + return super._moderatedChannels; + } + + @override + ObservableList get _moderatedChannels => moderatedChannels; + + @override + set _moderatedChannels(ObservableList value) { + _$_moderatedChannelsAtom.reportWrite(value, super._moderatedChannels, () { + super._moderatedChannels = value; + }); + } + late final _$initAsyncAction = AsyncAction( 'UserStoreBase.init', context: context, @@ -93,6 +113,76 @@ mixin _$UserStore on UserStoreBase, Store { ); } + late final _$refreshModeratedChannelsAsyncAction = AsyncAction( + 'UserStoreBase.refreshModeratedChannels', + context: context, + ); + + @override + Future refreshModeratedChannels() { + return _$refreshModeratedChannelsAsyncAction.run( + () => super.refreshModeratedChannels(), + ); + } + + late final _$deleteMessageAsyncAction = AsyncAction( + 'UserStoreBase.deleteMessage', + context: context, + ); + + @override + Future deleteMessage({ + required String broadcasterId, + required String messageId, + }) { + return _$deleteMessageAsyncAction.run( + () => super.deleteMessage( + broadcasterId: broadcasterId, + messageId: messageId, + ), + ); + } + + late final _$banOrTimeoutUserAsyncAction = AsyncAction( + 'UserStoreBase.banOrTimeoutUser', + context: context, + ); + + @override + Future banOrTimeoutUser({ + required String broadcasterId, + required String userIdToBan, + int? duration, + String? reason, + }) { + return _$banOrTimeoutUserAsyncAction.run( + () => super.banOrTimeoutUser( + broadcasterId: broadcasterId, + userIdToBan: userIdToBan, + duration: duration, + reason: reason, + ), + ); + } + + late final _$unbanUserAsyncAction = AsyncAction( + 'UserStoreBase.unbanUser', + context: context, + ); + + @override + Future unbanUser({ + required String broadcasterId, + required String userIdToUnban, + }) { + return _$unbanUserAsyncAction.run( + () => super.unbanUser( + broadcasterId: broadcasterId, + userIdToUnban: userIdToUnban, + ), + ); + } + late final _$UserStoreBaseActionController = ActionController( name: 'UserStoreBase', context: context, diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 938c6bb9..c6b2d413 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -290,6 +290,70 @@ void main() { }); }); + group('getModeratedChannels', () { + test('returns broadcaster IDs from a single page', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '111', 'broadcaster_login': 'a'}, + {'broadcaster_id': '222', 'broadcaster_login': 'b'}, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: {'user_id': '12345', 'first': '100'}, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, ['111', '222']); + }); + + test('follows the pagination cursor across pages', () async { + dioAdapter + ..onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '111'}, + ], + 'pagination': {'cursor': 'page2'}, + }), + queryParameters: {'user_id': '12345', 'first': '100'}, + ) + ..onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(200, { + 'data': [ + {'broadcaster_id': '222'}, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: { + 'user_id': '12345', + 'first': '100', + 'after': 'page2', + }, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, ['111', '222']); + }); + + test('returns an empty list when the scope is missing', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/moderation/channels', + (server) => server.reply(401, {'message': 'Missing scope'}), + queryParameters: {'user_id': '12345', 'first': '100'}, + ); + + final channels = await api.getModeratedChannels(id: '12345'); + + expect(channels, isEmpty); + }); + }); + group('getTopStreams', () { test('returns streams with pagination', () async { dioAdapter.onGet( From 03e4198fdd286b4eb21c558588159fd7b2f4fee3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:51:11 -0400 Subject: [PATCH 19/67] ci: fail on stale generated files run build_runner in both ci.yml (push to main) and pr-check.yml (PRs), then fail if git status is dirty. catches forgetting to regenerate a .g.dart after editing a MobX store or @JsonSerializable model, which analyze/test/build all miss (as #576 did). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/pr-check.yml | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce303d57..3a14cc5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,18 @@ jobs: cache: true - run: flutter doctor -v - run: flutter pub get + # Fail if committed generated files (*.g.dart) are stale — CI never runs + # build_runner otherwise, so forgetting to regenerate after editing a MobX + # store or @JsonSerializable model slips past analyze/test/build. + - run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated files are up to date + run: | + if [ -n "$(git status --porcelain)" ]; then + echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' + git status --porcelain + git diff + exit 1 + fi - run: flutter analyze - run: flutter test - run: flutter build apk --release diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 2b632d17..8ea53ae2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -20,6 +20,18 @@ jobs: cache: true - run: flutter doctor -v - run: flutter pub get + # Fail if committed generated files (*.g.dart) are stale — CI never runs + # build_runner otherwise, so forgetting to regenerate after editing a MobX + # store or @JsonSerializable model slips past analyze/test/build. + - run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated files are up to date + run: | + if [ -n "$(git status --porcelain)" ]; then + echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' + git status --porcelain + git diff + exit 1 + fi - run: flutter analyze - run: flutter test # Release-mode compile check (no signing secrets needed — Android falls back From 2cdd3e9fda55f6e519ddda2920ca629e4a1e5c4d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 19:52:09 -0400 Subject: [PATCH 20/67] build: pin exact flutter version in pubspec as single source of truth - add `flutter: 3.44.2` to environment so `flutter pub get` fails locally the moment the SDK drifts (was already drifting: local 3.44.2 vs the 3.44.1 pinned in CI) - point all 8 workflow pins at `flutter-version-file: pubspec.yaml` so CI reads the same field; bumping Flutter is now a one-line pubspec edit instead of editing 5 workflow files Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/gh-release.yml | 2 +- .github/workflows/pr-check.yml | 2 +- .github/workflows/release-dryrun.yml | 4 ++-- .github/workflows/store-release.yml | 4 ++-- pubspec.lock | 2 +- pubspec.yaml | 3 +++ 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a14cc5a..d6c5c96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: java-version: 21 - uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get @@ -53,7 +53,7 @@ jobs: xcode-version: latest-stable - uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true - run: flutter pub get - run: flutter build ios --release --no-codesign diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index c668613e..a0d23fd7 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8ea53ae2..1c3e9897 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -16,7 +16,7 @@ jobs: java-version: 21 - uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index 2c97dd62..b3c31b44 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -53,7 +53,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -105,7 +105,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index 353deb9b..db4fe7ef 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -49,7 +49,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -101,7 +101,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.44.1 + flutter-version-file: pubspec.yaml cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). diff --git a/pubspec.lock b/pubspec.lock index 4f4b25b0..bd05ccbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1530,4 +1530,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + flutter: "3.44.2" diff --git a/pubspec.yaml b/pubspec.yaml index 962a8bf4..e05eaf23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,9 @@ version: 5.1.0+69 environment: sdk: ^3.9.0 + # Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via + # `flutter-version-file: pubspec.yaml`. Single source of truth: bump here. + flutter: 3.44.2 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions From e2dff06f8dc59567ac59c68bf161b15202838c1a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:38:09 -0400 Subject: [PATCH 21/67] docs(agents): fix dart-define names, default to tests, drop lint-enforced style rules Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3501b26c..8b549d99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,8 @@ ## Code Opinions -- Package imports (`import 'package:frosty/...'`), not relative imports -- Trailing commas always -- Single quotes - Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering +- Default to writing tests: new or changed logic (stores, API clients, parsing, utils) ships with tests; when touching existing tested code, update or extend its tests. Pure UI/rendering or config changes may skip. ## Infrastructure Checklist @@ -29,9 +27,9 @@ Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state m ```bash flutter pub get # Install dependencies -flutter run --dart-define=clientId=ID --dart-define=secret=SECRET # Run with Twitch credentials +flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET # Run with Twitch credentials flutter analyze # Static analysis (run after changes) -flutter test # Run tests (if changes touch testable logic) +flutter test # Run tests dart run build_runner build # Regenerate .g.dart files (MobX/JSON models) dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs ``` From 65974ef954ec65d97f4df7df2db7d63bd6f5b68a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 18:42:48 -0400 Subject: [PATCH 22/67] docs(agents): add fork-pin gotcha, test layout, pre-push check Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8b549d99..5eb0a911 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ dart run build_runner build # Regenerate dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs ``` +Before pushing: `flutter analyze && flutter test`. + ## Source Structure - `lib/apis/` — API services (all extend `BaseApiClient`) @@ -54,10 +56,11 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control. - The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it. +- Several deps in `pubspec.yaml` are git-pinned forks carrying compatibility patches (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`). When upgrading dependencies, don't swap them back to pub.dev releases. ## Testing - `flutter test` runs all tests; `flutter test test/path/to/file.dart` for a single file - HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost` - General mocking: `mocktail` (no codegen required) -- Fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`) +- Test layout mirrors `lib/` (`test/apis/`, `test/models/`, `test/screens/`); shared fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`) From fb3508f43e682c068096b8994196c936f3a7b5bb Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 20:05:10 -0400 Subject: [PATCH 23/67] docs(agents): drop pre-push check, CI gates instead Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5eb0a911..929d2b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,6 @@ dart run build_runner build # Regenerate dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs ``` -Before pushing: `flutter analyze && flutter test`. - ## Source Structure - `lib/apis/` — API services (all extend `BaseApiClient`) From 98c3a031bc3e03a58c105eb0c8ecd4d27ac6540b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 2 Jul 2026 20:25:11 -0400 Subject: [PATCH 24/67] docs(agents): drop duplicated never section and checklist meta-advice, flatten layout Co-Authored-By: Claude Fable 5 --- AGENTS.md | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 929d2b53..90e3133c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,26 +1,5 @@ # AGENTS.md ---- - -# Author Preferences - -## Code Opinions - -- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering -- Default to writing tests: new or changed logic (stores, API clients, parsing, utils) ships with tests; when touching existing tested code, update or extend its tests. Pure UI/rendering or config changes may skip. - -## Infrastructure Checklist - -When creating new infrastructure (screens, API clients, stores), use exploration findings as a **checklist** — systematically verify each convention is followed before writing code. - -## Never - -- Never edit `.g.dart` files directly — always regenerate with `dart run build_runner build` - ---- - -# Project - Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state management with code generation. ## Commands @@ -28,7 +7,7 @@ Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state m ```bash flutter pub get # Install dependencies flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET # Run with Twitch credentials -flutter analyze # Static analysis (run after changes) +flutter analyze # Static analysis flutter test # Run tests dart run build_runner build # Regenerate .g.dart files (MobX/JSON models) dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs @@ -50,6 +29,11 @@ dart run build_runner build --delete-conflicting-outputs # Same, but - All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes - MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example +## Code Opinions + +- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering +- Default to writing tests: new or changed logic (stores, API clients, parsing, utils) ships with tests; when touching existing tested code, update or extend its tests. Pure UI/rendering or config changes may skip. + ## Gotchas - After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control. From abb6cbc4469693a9e6f31c7a7d923988ecaa2804 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 00:16:49 -0400 Subject: [PATCH 25/67] feat(chat): add bubbles for shared chat participant channels (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): add bubbles for shared chat participant channels Twitch's native shared chat previously had no way to interact with participant channels the way manually-added chats work. Adds per-participant bubbles alongside manual tabs: tapping one spotlights that channel (fading the rest), hiding one filters its messages from the feed entirely. Defaults to unfaded, resets on rejoin (fresh ChatStore per connect). Manual "merged mode" fading is untouched and unaffected. * refactor(chat): dedup shared-chat tag lookup and extract fade policy - add IRCMessage.sourceChannelId, replacing three copies of the same source-room-id/room-id tag lookup - move the shared-chat spotlight/merged-mode fade decision out of ChatMessage's build into ChatStore.shouldFadeMessage, matching the extract-into-store convention already used for SharedChatBubbleState - factor the tab-bar item's Padding/Center wrapper into a shared _wrapItem helper instead of duplicating it per branch * fix(chat): don't spotlight a hidden shared-chat bubble toggleFocus didn't check hiddenChannelIds, so tapping a hidden bubble's chip added it to the spotlight set — fading every other participant to showcase a channel whose own messages were already filtered out of the feed. * fix(chat): stop shared-chat detection flapping on system messages _isInSharedChatMode was recomputed from every PRIVMSG/NOTICE/USERNOTICE, but only PRIVMSG reliably carries source-room-id for a session's duration. A sub/raid/announcement without the tag would flip the flag off and back on within a single live session. Previously this only risked a redundant asset refetch (already guarded); now the shared chat bubble bar reads the flag directly, so a flap would visibly collapse and reopen it. Scope detection to PRIVMSG only. --- lib/models/irc.dart | 6 +- .../channel/chat/stores/chat_store.dart | 124 ++++++++++------ .../chat/stores/shared_chat_bubble_state.dart | 45 ++++++ .../channel/chat/widgets/chat_message.dart | 12 +- .../channel/chat/widgets/chat_tabs.dart | 133 ++++++++++++++++-- lib/screens/settings/chat_settings.dart | 4 +- .../chat/shared_chat_bubble_state_test.dart | 57 ++++++++ 7 files changed, 321 insertions(+), 60 deletions(-) create mode 100644 lib/screens/channel/chat/stores/shared_chat_bubble_state.dart create mode 100644 test/screens/channel/chat/shared_chat_bubble_state_test.dart diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 8e94cbb3..e7f7ce78 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -56,6 +56,11 @@ class IRCMessage { this.actionLabel, }); + /// The channel this message actually originated from. In Twitch shared + /// chat, `source-room-id` names the participant channel a message was + /// sent in; otherwise it's just this connection's own room. + String? get sourceChannelId => tags['source-room-id'] ?? tags['room-id']; + /// Applies the given CLEARCHAT message to the provided messages and buffer. static void clearChat({ required List messages, @@ -186,7 +191,6 @@ class IRCMessage { span.add(const TextSpan(text: ' ')); } - final sourceChannelId = tags['source-room-id'] ?? tags['room-id']; final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId]; if (sourceChannelUser != null) { final isCurrentChannel = sourceChannelId == currentChannelId; diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index d5b60e49..356d09db 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -13,6 +13,7 @@ import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/banned_user_tracker.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart'; +import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; @@ -201,11 +202,40 @@ abstract class ChatStoreBase with Store { /// The set of message IDs that have been revealed by the user (for deleted messages). final revealedMessageIds = ObservableSet(); + /// User-driven spotlight/hide selections for shared chat participant + /// channels. See [SharedChatBubbleState]. + final sharedChatBubbles = SharedChatBubbleState(); + @action void revealMessage(String id) { revealedMessageIds.add(id); } + /// Whether a message from [sourceChannelId] should render faded. + /// + /// [overrideCurrentChannelId] is set only when rendering the manual + /// "merged mode" view (multiple manually-added tabs interleaved), which + /// always fades non-active-tab messages, gated by [focusCurrentChannel]. + /// Otherwise this is the normal single-connection view, where native + /// Twitch shared chat defaults to unfaded and only fades once the user + /// spotlights a participant channel via a chat bubble — an explicit + /// action that isn't gated by that setting. + bool shouldFadeMessage({ + required String? sourceChannelId, + required String? overrideCurrentChannelId, + }) { + if (sourceChannelId == null) return false; + + final currentChannelId = overrideCurrentChannelId ?? channelId; + if (sourceChannelId == currentChannelId) return false; + + final isMerged = overrideCurrentChannelId != null; + if (isMerged) return settings.focusCurrentChannel; + + return sharedChatBubbles.focusedChannelIds.isNotEmpty && + !sharedChatBubbles.isFocused(sourceChannelId); + } + /// Timer used for dismissing the notification. Timer? _notificationTimer; @@ -242,14 +272,18 @@ abstract class ChatStoreBase with Store { List get renderMessages { // If autoscroll is disabled, render ALL messages in chat. // The second condition is to prevent an out of index error with sublist. - if (!_autoScroll || _messages.length < _renderMessageLimit) { - return _messages; - } + final base = (!_autoScroll || _messages.length < _renderMessageLimit) + ? _messages + : _messages.sublist(_messages.length - _renderMessageLimit); - // When autoscroll is enabled, only show the first [_renderMessageLimit] messages. - // This will improve performance by only rendering a limited amount of messages - // instead of the entire history at all times. - return _messages.sublist(_messages.length - _renderMessageLimit); + // Filter out messages from shared chat participant channels the user + // has hidden. Short-circuit when nothing is hidden (the common case). + if (sharedChatBubbles.hiddenChannelIds.isEmpty) return base; + return base.where((message) { + final sourceChannelId = message.sourceChannelId; + return sourceChannelId == null || + !sharedChatBubbles.hiddenChannelIds.contains(sourceChannelId); + }).toList(); } /// If the chat should automatically scroll/jump to the latest message. @@ -662,41 +696,49 @@ abstract class ChatStoreBase with Store { } } - // Update shared chat mode based on source-room-id tag presence - final wasShared = _isInSharedChatMode; - _isInSharedChatMode = parsedIRCMessage.tags.containsKey( - 'source-room-id', - ); - // On transition into shared chat mode, fetch assets for participants. - if (!wasShared && _isInSharedChatMode) { - assetsStore.fetchSharedChatAssets( - channelId: channelId, - headers: auth.headersTwitch, - onEmoteError: (error) { - debugPrint(error.toString()); - return []; - }, - onBadgeError: (error) { - debugPrint(error.toString()); - return {}; - }, - showTwitchEmotes: settings.showTwitchEmotes, - showTwitchBadges: settings.showTwitchBadges, - show7TVEmotes: settings.show7TVEmotes, - showBTTVEmotes: settings.showBTTVEmotes, - showFFZEmotes: settings.showFFZEmotes, - showFFZBadges: settings.showFFZBadges, + // Update shared chat mode based on source-room-id tag presence. + // Only PRIVMSG reliably carries this tag for the session's + // duration — NOTICE/USERNOTICE (subs, raids, announcements) + // don't always, so gating on those too would flap this flag off + // and on within a single live session (now visibly, since the + // shared chat bubble bar reads it — previously this only risked + // a redundant asset refetch, guarded below). + if (parsedIRCMessage.command == Command.privateMessage) { + final wasShared = _isInSharedChatMode; + _isInSharedChatMode = parsedIRCMessage.tags.containsKey( + 'source-room-id', ); - } else if (wasShared && - !_isInSharedChatMode && - assetsStore.hasLoadedSharedChatAssets) { - // On leaving shared chat, drop the participants' merged emotes and - // badges and restore just this channel's assets — otherwise the - // other streamer's emote set and badges linger (#522). Guarded on - // having actually loaded shared assets so a flapping tag can't - // trigger redundant refetches. - assetsStore.resetSharedChatAssets(); - getAssets(); + // On transition into shared chat mode, fetch assets for participants. + if (!wasShared && _isInSharedChatMode) { + assetsStore.fetchSharedChatAssets( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return {}; + }, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + } else if (wasShared && + !_isInSharedChatMode && + assetsStore.hasLoadedSharedChatAssets) { + // On leaving shared chat, drop the participants' merged emotes and + // badges and restore just this channel's assets — otherwise the + // other streamer's emote set and badges linger (#522). Guarded on + // having actually loaded shared assets so a flapping tag can't + // trigger redundant refetches. + assetsStore.resetSharedChatAssets(); + getAssets(); + } } messageBuffer.add(parsedIRCMessage); break; diff --git a/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart new file mode 100644 index 00000000..6d18c655 --- /dev/null +++ b/lib/screens/channel/chat/stores/shared_chat_bubble_state.dart @@ -0,0 +1,45 @@ +import 'package:mobx/mobx.dart'; + +/// Tracks user-driven spotlight/hide selections for participant channels in +/// a Twitch shared chat session (multiple broadcasters merged onto one IRC +/// connection). Pure state so it can be unit-tested without the chat store. +/// +/// [ObservableSet] is reactive on its own, so [Observer] widgets reading +/// [focusedChannelIds]/[hiddenChannelIds] rebuild without any MobX codegen. +class SharedChatBubbleState { + /// Channel ids the user has spotlighted (kept unfaded). Empty means no + /// spotlight is active and nothing is faded. + final focusedChannelIds = ObservableSet(); + + /// Channel ids whose messages are filtered out of the feed entirely. + final hiddenChannelIds = ObservableSet(); + + /// Adds/removes [channelId] from the spotlight set. No-ops for a hidden + /// channel — its messages are filtered out, so spotlighting it would fade + /// every other channel to showcase nothing. + void toggleFocus(String channelId) { + if (hiddenChannelIds.contains(channelId)) return; + if (!focusedChannelIds.remove(channelId)) { + focusedChannelIds.add(channelId); + } + } + + /// Hides [channelId]'s messages and drops it from the spotlight set. + void hide(String channelId) { + hiddenChannelIds.add(channelId); + focusedChannelIds.remove(channelId); + } + + /// Un-hides [channelId]'s messages. + void show(String channelId) => hiddenChannelIds.remove(channelId); + + bool isFocused(String channelId) => focusedChannelIds.contains(channelId); + + bool isHidden(String channelId) => hiddenChannelIds.contains(channelId); + + /// Clears both sets (e.g. when a shared chat session ends). + void reset() { + focusedChannelIds.clear(); + hiddenChannelIds.clear(); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 3bc8c289..339254dc 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -626,14 +626,10 @@ class ChatMessage extends StatelessWidget { child: dividedMessage, ); - final sourceChannelId = - ircMessage.tags['source-room-id'] ?? ircMessage.tags['room-id']; - final currentChannelId = - overrideCurrentChannelId ?? chatStore.channelId; - final shouldFade = - chatStore.settings.focusCurrentChannel && - sourceChannelId != null && - sourceChannelId != currentChannelId; + final shouldFade = chatStore.shouldFadeMessage( + sourceChannelId: ircMessage.sourceChannelId, + overrideCurrentChannelId: overrideCurrentChannelId, + ); final fadedMessage = shouldFade ? Opacity(opacity: 0.55, child: coloredMessage) diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 724564aa..490ce0df 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/chat.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart'; @@ -48,13 +49,32 @@ class ChatTabs extends StatelessWidget { } } + /// Wraps a tab bar item (a tab chip or a shared-chat bubble) with its + /// trailing gap and centering, keyed for the reorderable list. + Widget _wrapItem({required Key key, required bool isLast, required Widget child}) { + return Padding( + key: key, + padding: EdgeInsets.only(right: isLast ? 0 : 4), + child: Center(child: child), + ); + } + @override Widget build(BuildContext context) { return Observer( builder: (context) { final tabs = chatTabsStore.tabs; final activeIndex = chatTabsStore.activeTabIndex; - final showTabBar = chatTabsStore.showTabBar; + final activeStore = chatTabsStore.activeChatStore; + // Twitch native shared chat participants (excluding the active + // channel itself, which is already shown as its own tab chip). + final sharedChatParticipants = activeStore.isInSharedChatMode + ? activeStore.assetsStore.channelIdToUserTwitch.entries + .where((entry) => entry.key != activeStore.channelId) + .toList() + : const >[]; + final showTabBar = + chatTabsStore.showTabBar || sharedChatParticipants.isNotEmpty; final showMerge = tabs.where((t) => t.isActivated).length >= 2; final tabBarHeight = showTabBar ? 48.0 : 0.0; @@ -123,12 +143,19 @@ class ChatTabs extends StatelessWidget { right: showMerge ? 33 : 0, child: ReorderableListView.builder( scrollDirection: Axis.horizontal, - itemCount: tabs.length, + itemCount: tabs.length + sharedChatParticipants.length, padding: EdgeInsets.only( left: 12, right: showMerge ? 32 : 12, ), onReorderItem: (oldIndex, newIndex) { + // Shared chat bubbles are trailing, fixed-order + // items (not real tabs) — ignore any reorder + // that touches them. + if (oldIndex >= tabs.length || + newIndex >= tabs.length) { + return; + } HapticFeedback.lightImpact(); chatTabsStore.reorderTab(oldIndex, newIndex); }, @@ -139,13 +166,30 @@ class ChatTabs extends StatelessWidget { ); }, itemBuilder: (context, index) { - final tabInfo = chatTabsStore.tabs[index]; - return Padding( - key: ValueKey(tabInfo.channelId), - padding: EdgeInsets.only( - right: index < tabs.length - 1 ? 4 : 0, + final isLast = + index == + tabs.length + + sharedChatParticipants.length - + 1; + if (index < tabs.length) { + final tabInfo = chatTabsStore.tabs[index]; + return _wrapItem( + key: ValueKey(tabInfo.channelId), + isLast: isLast, + child: _buildTab(context, index), + ); + } + + final participant = + sharedChatParticipants[index - tabs.length]; + return _wrapItem( + key: ValueKey('shared_${participant.key}'), + isLast: isLast, + child: _buildSharedChatBubble( + context, + participant.key, + participant.value, ), - child: Center(child: _buildTab(context, index)), ); }, ), @@ -304,6 +348,79 @@ class ChatTabs extends StatelessWidget { }, ); } + + /// Builds a bubble for a Twitch native shared chat participant channel. + /// Unlike manual tabs, these don't own a connection: tapping spotlights + /// the channel (unfades it, fading the rest — see [chat_message.dart]), + /// and the trailing menu hides/shows the channel's messages instead of + /// disconnecting/removing a tab. + Widget _buildSharedChatBubble( + BuildContext context, + String channelId, + UserTwitch user, + ) { + final activeStore = chatTabsStore.activeChatStore; + final displayName = getReadableName(user.displayName, user.login); + + return Observer( + builder: (context) { + final theme = Theme.of(context); + final isFocused = activeStore.sharedChatBubbles.isFocused(channelId); + final isHidden = activeStore.sharedChatBubbles.isHidden(channelId); + + InputChip buildChip({VoidCallback? onDeleted}) => InputChip( + avatar: Opacity( + opacity: isHidden ? 0.5 : 1.0, + child: ProfilePicture(userLogin: user.login, radius: 12), + ), + label: Text( + displayName, + style: isHidden + ? TextStyle( + color: theme.textTheme.bodyMedium?.color + ?.withValues(alpha: 0.5), + ) + : null, + ), + selected: isFocused, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + HapticFeedback.selectionClick(); + activeStore.sharedChatBubbles.toggleFocus(channelId); + }, + onDeleted: onDeleted, + deleteButtonTooltipMessage: + onDeleted != null ? 'Channel options' : null, + ); + + return _AnchoredPopupMenu( + itemsBuilder: (close) => [ + MenuItemButton( + leadingIcon: Icon( + isHidden + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + size: 18, + ), + child: Text(isHidden ? 'Show channel' : 'Hide channel'), + onPressed: () { + HapticFeedback.lightImpact(); + close(); + if (isHidden) { + activeStore.sharedChatBubbles.show(channelId); + } else { + activeStore.sharedChatBubbles.hide(channelId); + } + }, + ), + ], + anchorBuilder: (context, toggle) => buildChip(onDeleted: toggle), + ); + }, + ); + } } /// Lightweight popup menu anchored to a child widget. The menu's top-right diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 16b086ef..2cd98029 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -224,8 +224,8 @@ class ChatSettings extends StatelessWidget { SettingsListSwitch( title: 'Focus current channel', subtitle: const Text( - 'Fades messages from other channels in shared chat and ' - 'merged views, so the current channel stands out.', + 'Fades messages from other channels in merged chat, ' + 'so the current channel stands out.', ), value: settingsStore.focusCurrentChannel, onChanged: (newValue) => diff --git a/test/screens/channel/chat/shared_chat_bubble_state_test.dart b/test/screens/channel/chat/shared_chat_bubble_state_test.dart new file mode 100644 index 00000000..65a2b927 --- /dev/null +++ b/test/screens/channel/chat/shared_chat_bubble_state_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/chat/stores/shared_chat_bubble_state.dart'; + +void main() { + late SharedChatBubbleState state; + setUp(() => state = SharedChatBubbleState()); + + test('a channel is neither focused nor hidden by default', () { + expect(state.isFocused('1'), isFalse); + expect(state.isHidden('1'), isFalse); + }); + + test('toggleFocus spotlights an unfocused channel', () { + state.toggleFocus('1'); + expect(state.isFocused('1'), isTrue); + }); + + test('toggleFocus un-spotlights an already-focused channel', () { + state.toggleFocus('1'); + state.toggleFocus('1'); + expect(state.isFocused('1'), isFalse); + }); + + test('multiple channels can be spotlighted at once', () { + state.toggleFocus('1'); + state.toggleFocus('2'); + expect(state.isFocused('1'), isTrue); + expect(state.isFocused('2'), isTrue); + }); + + test('hide filters a channel and drops it from the spotlight', () { + state.toggleFocus('1'); + state.hide('1'); + expect(state.isHidden('1'), isTrue); + expect(state.isFocused('1'), isFalse); + }); + + test('toggleFocus is a no-op for a hidden channel', () { + state.hide('1'); + state.toggleFocus('1'); + expect(state.isFocused('1'), isFalse); + }); + + test('show lifts a hide', () { + state.hide('1'); + state.show('1'); + expect(state.isHidden('1'), isFalse); + }); + + test('reset clears both focused and hidden channels', () { + state.toggleFocus('1'); + state.hide('2'); + state.reset(); + expect(state.isFocused('1'), isFalse); + expect(state.isHidden('2'), isFalse); + }); +} From b2a32d178f31ea62c934f451c30aa836b784463b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 9 Jul 2026 00:13:05 -0700 Subject: [PATCH 26/67] build: bump flutter pin to 3.44.5 picks up the Impeller text/icon shadow positioning fix from #188035. Co-authored-by: Cursor --- pubspec.lock | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index bd05ccbf..9e8b63a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1530,4 +1530,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: "3.44.2" + flutter: "3.44.5" diff --git a/pubspec.yaml b/pubspec.yaml index e05eaf23..3bc63b18 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ environment: sdk: ^3.9.0 # Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via # `flutter-version-file: pubspec.yaml`. Single source of truth: bump here. - flutter: 3.44.2 + flutter: 3.44.5 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions From 2a203aa0d13d09900ab902c7815d4ca86a2b0a34 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 11:47:21 -0700 Subject: [PATCH 27/67] fix(video): re-run player init on every webview page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_needsInit` replaced the JS-side `window._injected` guard in 9cec89a, but the two have different lifetimes. `window._injected` died with the page, so any page load re-ran `initVideo()`. `_needsInit` lives on the store and was only re-armed by `handleRefresh()`. The store outlives the Video widget, which loads `videoUrl` on every mount. Those reloads hit `onPageFinished` with `_needsInit` already false and returned early, so the player came back with no play/pause listeners, no cached `_videoEl`, no autoplay call and no overlay CSS — visibly stuck paused with a play button that did nothing, unrecoverable without an app restart. Re-arm on `onPageStarted` so every navigation re-initializes, restoring the page-scoped lifetime while keeping the Dart-side control that fixed the original race. Co-Authored-By: Claude Opus 5 --- lib/screens/channel/video/video_store.dart | 108 +++++++++++++++++---- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index f74d58ce..cdf22744 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -55,10 +55,24 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Whether [initVideo] should run on the next [onPageFinished]. /// - /// Dart-controlled instead of JS-side (`window._injected`) to avoid race - /// conditions when `loadRequest` fires before the previous page fully loads. + /// Dart-controlled rather than JS-side (`window._injected`) to avoid race + /// conditions when `loadRequest` fires before the previous page fully + /// loads — but re-armed by `onPageStarted` on *every* navigation so it + /// keeps the page-scoped lifetime the JS flag had for free. This controller + /// outlives the [Video] widget, which reloads [videoUrl] on each mount, and + /// a flag only [handleRefresh] reset left those reloads permanently + /// uninitialized: no play/pause listeners, no `_videoEl`, no autoplay, no + /// overlay CSS, and no recovery short of restarting the app. var _needsInit = true; + /// Whether the *user* paused, as opposed to the page pausing itself. + /// + /// Backgrounding can suspend playback with no way for the page to resume on + /// its own — the native store recovers via `play()` on resume, the webview + /// store had no equivalent. This distinguishes "resume it for them" from + /// "they meant it". + var _userPaused = false; + /// The video web view params used for enabling auto play. late final PlatformWebViewControllerCreationParams _videoWebViewParams; @@ -158,28 +172,18 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { ) ..addJavaScriptChannel( 'PipEntered', - onMessageReceived: (message) { - _overlayWasVisibleBeforePip = _overlayVisible; - _isInPipMode = true; - _overlayTimer?.cancel(); - _overlayVisible = true; - }, + onMessageReceived: (message) => _setPipActive(true), ) ..addJavaScriptChannel( 'PipExited', - onMessageReceived: (message) { - _isInPipMode = false; - if (_overlayWasVisibleBeforePip) { - _updateLatencyTrackerVisibility(true); - _scheduleOverlayHide(); - } else { - _overlayVisible = false; - _updateLatencyTrackerVisibility(false); - } - }, + onMessageReceived: (message) => _setPipActive(false), ) ..setNavigationDelegate( NavigationDelegate( + // Every real navigation invalidates the injected JS context, so + // every one must re-arm init. Unconditional is safe: the + // onPageFinished handler below still filters on videoUrl. + onPageStarted: (url) => _needsInit = true, onPageFinished: (url) async { if (url != videoUrl) return; if (!_needsInit) return; @@ -711,14 +715,71 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { }); } - /// Handles app resume event for immediate stream info refresh in chat-only mode. + @override + @action + void handleAndroidPipChanged(bool isInPip) => _setPipActive(isInPip); + + /// Shared PiP enter/exit state machine. + /// + /// Driven by the Android activity callback and, on iOS, by the web PiP API + /// events posted from the page. Android previously reached neither — the + /// callback was routed only to the native store — so `_isInPipMode` stayed + /// false for the whole PiP session and the overlay kept auto-hiding. + @action + void _setPipActive(bool isInPip) { + if (isInPip == _isInPipMode) return; + + if (isInPip) { + _overlayWasVisibleBeforePip = _overlayVisible; + _isInPipMode = true; + _overlayTimer?.cancel(); + _overlayVisible = true; + } else { + _isInPipMode = false; + if (_overlayWasVisibleBeforePip) { + _updateLatencyTrackerVisibility(true); + _scheduleOverlayHide(); + } else { + _overlayVisible = false; + _updateLatencyTrackerVisibility(false); + } + } + } + + /// Resumes playback when the page paused itself rather than the user doing + /// it. Safe to call spuriously — `play()` on an already-playing element is + /// a no-op. + /// + /// Deliberately not called on PiP transitions: Twitch's embed refuses to + /// play below 400x300 CSS pixels, and an Android PiP window is far under + /// that, so a resume there is rejected no matter what we do. + void _resumeIfSuspended() { + if (!settingsStore.showVideo || !_paused || _userPaused) return; + try { + videoWebViewController.runJavaScript( + '(window._videoEl || document.getElementsByTagName("video")[0])?.play();', + ); + } catch (e) { + debugPrint(e.toString()); + } + } + + /// Handles the app returning to the foreground. + /// + /// In chat-only mode this just refreshes the stream info. In video mode it + /// also resumes playback if the page paused itself while backgrounded — + /// the webview can be suspended when detached from the window and has no + /// way to restart on its own, which previously left the player dead until + /// a manual refresh. A pause the user asked for is left alone. @override @action void handleAppResume() { - // Only refresh immediately in chat-only mode if (!settingsStore.showVideo) { updateStreamInfo(forceUpdate: true); + return; } + + _resumeIfSuspended(); } /// Updates the stream info from the Twitch API. @@ -775,6 +836,7 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { HapticFeedback.lightImpact(); _loading = true; _paused = true; + _userPaused = false; _firstTimeSettingQuality = true; _highLatencyCount = 0; _isInPipMode = false; @@ -784,7 +846,8 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { _overlayTimer?.cancel(); _scheduleOverlayHide(); - // Signal that initVideo() should run on the next onPageFinished + // Belt-and-braces: onPageStarted re-arms this for the loadRequest below + // too, but don't depend on the delegate firing for an explicit refresh. _needsInit = true; _stopLatencyTracker(); @@ -797,6 +860,9 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { /// Play or pause the video depending on the current state of [_paused]. @override void handlePausePlay() { + // Remember the user's intent so handleAppResume knows whether a pause is + // theirs or something the page did to itself. + _userPaused = !_paused; try { if (_paused) { videoWebViewController.runJavaScript( From 880a9c92aa470547bc3bfcf8cac0f4ea9244a5d8 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 11:47:54 -0700 Subject: [PATCH 28/67] fix(video): sync android pip state and playback across both players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related PiP gaps on Android: `handleAndroidPipChanged` lived on `NativeVideoPlayerInterface`, and `notifyPip` type-checked against it, so the WebView store never learned it was in PiP — `_isInPipMode` stayed false for the whole session and the overlay kept auto-hiding behind the PiP window. Moved to the shared `VideoPlayerInterface` and implemented in both stores. The WebView store's two JS PiP channel bodies collapse into one `_setPipActive` state machine, now shared with the Android callback. `NativeVideoStore` never called `_pip.setIsPlaying()`, so with the `mediaOnlyPause` layout the PiP window's button was stuck on whatever glyph it started with, and `onPipAction` — which toggles blind — could do the opposite of what the button showed. `VideoStore.handleAppResume()` did nothing in video mode, while the native store calls `play()`. Backgrounding can suspend the webview's playback with no way for the page to restart itself, leaving it dead until a manual refresh. Resumes now unless the user was the one who paused. Not fixed: PiP with the WebView player. Twitch's embed refuses to play below 400x300 CSS pixels and a PiP window is far under that, Android resizes the activity before firing onPictureInPictureModeChanged so any correction arrives too late, and the embed latches once tripped. The native player has no such constraint. Refs #563. Co-Authored-By: Claude Opus 5 --- lib/screens/channel/channel.dart | 11 ++++------- .../channel/video/native_video_player_interface.dart | 4 ---- lib/screens/channel/video/native_video_store.dart | 12 ++++++++++++ .../channel/video/video_player_interface.dart | 5 +++++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index c66dae36..c65c0e4b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -7,7 +7,6 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; import 'package:frosty/screens/channel/video/native_video.dart'; -import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/video.dart'; @@ -772,12 +771,10 @@ class _VideoChatState extends State // If on Android, use PiPSwitcher to enable PiP functionality. if (Platform.isAndroid) { - void notifyPip(bool isInPip) { - final store = _videoStore; - if (store is NativeVideoPlayerInterface) { - store.handleAndroidPipChanged(isInPip); - } - } + // Both players need this — the WebView store used to be skipped by a + // NativeVideoPlayerInterface type check, so it never learned it was in + // PiP on Android. + void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip); return PipWidget( pipLayout: PipActionsLayout.mediaOnlyPause, diff --git a/lib/screens/channel/video/native_video_player_interface.dart b/lib/screens/channel/video/native_video_player_interface.dart index 766d5a3c..a01e34ec 100644 --- a/lib/screens/channel/video/native_video_player_interface.dart +++ b/lib/screens/channel/video/native_video_player_interface.dart @@ -5,8 +5,4 @@ import 'package:frosty/screens/channel/video/video_player_interface.dart'; abstract class NativeVideoPlayerInterface extends VideoPlayerInterface { /// Whether the current quality is an HLS audio-only variant. bool get isAudioOnlyMode; - - /// Called from platform PiP callbacks on Android. Lets the store sync - /// its overlay state with the OS PiP window. - void handleAndroidPipChanged(bool isInPip); } diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index e0b789bc..e32a276c 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -499,9 +499,19 @@ abstract class NativeVideoStoreBase }); } + /// Keeps the Android PiP window's play/pause remote action in sync with + /// actual playback. Without this the button is stuck on whatever glyph the + /// `mediaOnlyPause` layout started with, and `onPipAction` — which toggles + /// blind — then does the opposite of what the button shows. + void _syncPipPlaybackState({required bool isPlaying}) { + if (!Platform.isAndroid) return; + _pip.setIsPlaying(isPlaying); + } + void _onPlaying() { _loading = false; _paused = false; + _syncPipPlaybackState(isPlaying: true); _isStalled = false; _hasPlayedOnce = true; _initializing = false; @@ -543,6 +553,7 @@ abstract class NativeVideoStoreBase void _onPlayerError(Map? eventData) { _loading = false; _paused = true; + _syncPipPlaybackState(isPlaying: false); _isStalled = false; _initializing = false; _isQualitySwitching = false; @@ -667,6 +678,7 @@ abstract class NativeVideoStoreBase void _onPaused() { _healthyPlaybackTimer?.cancel(); _paused = true; + _syncPipPlaybackState(isPlaying: false); _isStalled = false; _isQualitySwitching = false; _stallRecoveryTimer?.cancel(); diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart index b2507f1f..9838e7ad 100644 --- a/lib/screens/channel/video/video_player_interface.dart +++ b/lib/screens/channel/video/video_player_interface.dart @@ -39,5 +39,10 @@ abstract class VideoPlayerInterface { Future setStreamQuality(String quality); Future updateStreamInfo({bool forceUpdate}); void handleAppResume(); + + /// Called from the platform PiP callbacks on Android so the store can sync + /// its overlay state, and its playback, with the OS PiP window. + void handleAndroidPipChanged(bool isInPip); + void dispose(); } From 14591b3bf801391767e53fa798ddebe45d293a22 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 15:20:52 -0700 Subject: [PATCH 29/67] build(video): pull in native player low-latency HLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps better_native_video_player to the frosty-patches tip, which now carries the Twitch low-latency work: #EXT-X-TWITCH-PREFETCH segments are promoted into real segments, EXT-X-TARGETDURATION is rewritten to the true ~2s cadence so media3 stops starving on reloads, and the LoadControl no longer parks playback ~5s behind after a rebuffer. Takes native live latency on Android from ~9s to ~3s. iOS is unaffected — AVFoundation parses HLS internally, so the same rewrite needs an AVAssetResourceLoaderDelegate to intercept the playlist. Note the readout: getLatencyToLive() measures distance to the playlist's live edge, while Twitch's own "Latency To Broadcaster" stat adds ingest and packaging on top, so the two aren't directly comparable. Refs #573 Co-Authored-By: Claude Opus 5 --- pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index 9e8b63a4..d8fdc23c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,7 +102,7 @@ packages: description: path: "." ref: frosty-patches - resolved-ref: "6460859318b7b44034fe44016cddff02d0bd4c0a" + resolved-ref: "19c627547c227f1fc9895a5a62839e64fec689b3" url: "https://github.com/tommyxchow/flutter-native-video-player.git" source: git version: "0.4.11" From 36aa5e73686eadb4deef735ac6ea7978de8a2c3e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 15:43:35 -0700 Subject: [PATCH 30/67] fix(video): regenerate video store bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 880a9c9 added the handleAndroidPipChanged and _setPipActive actions to VideoStoreBase without regenerating the mixin, so neither was wrapped by the MobX action controller. Both still ran, but their observable writes landed as separate notifications instead of one batched transaction — extra rebuilds on every PiP transition. Analyze and the test suite pass either way; only CI's generated-file diff check catches it. Co-Authored-By: Claude Opus 5 --- lib/screens/channel/video/video_store.g.dart | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index c12540e5..13e704fc 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -278,6 +278,30 @@ mixin _$VideoStore on VideoStoreBase, Store { } } + @override + void handleAndroidPipChanged(bool isInPip) { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.handleAndroidPipChanged', + ); + try { + return super.handleAndroidPipChanged(isInPip); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _setPipActive(bool isInPip) { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase._setPipActive', + ); + try { + return super._setPipActive(isInPip); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void handleAppResume() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( From c939dc279098978975b0fe35c7c9f30b71ef6920 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 15:43:51 -0700 Subject: [PATCH 31/67] ci: give the release dry-run an unused android version code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play rejects a version code it has already seen, and between releases pubspec's code is by definition already published — so every scheduled dry-run died on "Version code 69 has already been used" before reaching any of the credential, signing, or store validation it exists to check. It has been failing on that since 5.1.0 shipped. Overrides the build number with epoch seconds, which is always unused and always increasing. The bundle is validate_only, never published, so the value is discarded. Only downside is the dry-run can no longer catch a forgotten version bump, which the real release catches anyway. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-dryrun.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index b3c31b44..fd6d0c2d 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -69,8 +69,16 @@ jobs: echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH # Build the Android app bundle (AAB) and pass the environment variables. + # + # The version code is overridden because Play rejects one it has already + # seen, and pubspec's code is by definition already published between + # releases — so the dry-run would fail on every scheduled run and never + # reach the validation it exists to perform. Epoch seconds is always + # unused, always increasing, and well under Play's 2100000000 cap. The + # bundle is only ever validated, never published, so the value is thrown + # away. versionName still comes from pubspec. - name: Build the Android AAB - run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET # Validate the AAB and credentials against Google Play without publishing. - name: Validate release with fastlane (no publish) From 601088b5c667934e5a7147f094fa1fcb0d129e09 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 16:04:15 -0700 Subject: [PATCH 32/67] ci: bump ruby to 3.3 and report scheduled dry-run failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastlane warns on every run that it will soon require Ruby 3.3.0 or newer; both release workflows pinned 3.2. Bumping now means that break lands on a quiet commit instead of mid-release. fastlane 2.234 already supports 3.3 and no gem in either lockfile is version-sensitive, so this stands alone — the lockfiles are unchanged. Also gives the weekly dry-run somewhere to report. It broke on the release after 5.1.0 and stayed red for weeks without surfacing, which is the failure mode of any heartbeat nobody reads: it looks green by default. Scheduled failures now open a tracking issue, or comment on the existing one for each week it stays red. Dispatched runs are excluded — those are already in front of whoever started them. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-dryrun.yml | 51 ++++++++++++++++++++++++++-- .github/workflows/store-release.yml | 4 +-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index fd6d0c2d..3400ab27 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 3.3 bundler-cache: true # Setup Java so that we can build the appbundle. @@ -106,7 +106,7 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 3.3 bundler-cache: true # Setup and cache Flutter to install packages and build. @@ -124,3 +124,50 @@ jobs: # fully signed archive without uploading to the App Store. - name: Sign and build with fastlane (no publish) run: cd ios && bundle exec fastlane release dry_run:true + + # A heartbeat nobody reads is worse than none: it reads as green by default. + # This one broke on the release after 5.1.0 and stayed broken for weeks + # without surfacing, so scheduled failures now open a tracking issue and + # comment on it each week it stays red. Only on `schedule` — a failed + # workflow_dispatch is already in front of whoever pressed the button. + notify: + name: Report scheduled failure + needs: [android, ios] + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Open or update the tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + LABEL: release-dryrun + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + # Idempotent: `gh issue create --label` fails outright if the label + # is missing, and it won't exist on the first failure. + gh label create "$LABEL" \ + --color B60205 \ + --description "Automated: the weekly release dry-run is failing" \ + 2>/dev/null || true + + existing=$(gh issue list --state open --label "$LABEL" --limit 1 \ + --json number --jq '.[0].number // empty') + + if [ -n "$existing" ]; then + gh issue comment "$existing" --body \ + "Still failing as of $(date -u +%Y-%m-%d). $RUN_URL" + else + gh issue create \ + --title "Release dry-run is failing" \ + --label "$LABEL" \ + --body "$(printf '%s\n' \ + "The weekly release dry-run failed: $RUN_URL" \ + "" \ + "This checks the release pipeline between releases — Play and App Store credentials, signing certs and provisioning profiles, and that a release build still validates against both stores. A failure here means the next release is likely to fail too, so it's worth fixing before cutting one." \ + "" \ + "Closing this issue is fine once the run is green; a later failure opens a new one.")" + fi diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index db4fe7ef..53365a9e 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 3.3 bundler-cache: true # Setup Java so that we can build the appbundle. @@ -94,7 +94,7 @@ jobs: - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 3.3 bundler-cache: true # Setup and cache Flutter to install packages and build. From a21cf192eba07759cc3e3cf3d1ad01d3f59d26c2 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 8 Aug 2026 16:15:52 -0700 Subject: [PATCH 33/67] ci: drop the dry-run failure notifier and run it monthly Reverts the tracking-issue job from 601088b: a single maintainer watching the Actions tab doesn't need a bot filing issues at them, and GitHub already emails on scheduled workflow failure. Also moves the heartbeat from weekly to monthly. Nothing it watches -- cert expiry, secret rotation, dependency drift -- moves on a weekly timescale, and a dispatch run before cutting a release is the check that actually matters. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-dryrun.yml | 51 ++-------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index 3400ab27..0b905936 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -7,10 +7,10 @@ name: Release dry-run on: # Run on demand before cutting a release. workflow_dispatch: - # Weekly heartbeat so cert expiry, secret rotation, and dependency drift + # Monthly heartbeat so cert expiry, secret rotation, and dependency drift # surface on their own between releases. schedule: - - cron: '0 9 * * 1' # Mondays 09:00 UTC + - cron: '0 9 1 * *' # 1st of the month, 09:00 UTC # Setup environment variables for use in all jobs. env: # Gemfile path used for caching dependencies. @@ -124,50 +124,3 @@ jobs: # fully signed archive without uploading to the App Store. - name: Sign and build with fastlane (no publish) run: cd ios && bundle exec fastlane release dry_run:true - - # A heartbeat nobody reads is worse than none: it reads as green by default. - # This one broke on the release after 5.1.0 and stayed broken for weeks - # without surfacing, so scheduled failures now open a tracking issue and - # comment on it each week it stays red. Only on `schedule` — a failed - # workflow_dispatch is already in front of whoever pressed the button. - notify: - name: Report scheduled failure - needs: [android, ios] - if: failure() && github.event_name == 'schedule' - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - name: Open or update the tracking issue - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - LABEL: release-dryrun - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - - # Idempotent: `gh issue create --label` fails outright if the label - # is missing, and it won't exist on the first failure. - gh label create "$LABEL" \ - --color B60205 \ - --description "Automated: the weekly release dry-run is failing" \ - 2>/dev/null || true - - existing=$(gh issue list --state open --label "$LABEL" --limit 1 \ - --json number --jq '.[0].number // empty') - - if [ -n "$existing" ]; then - gh issue comment "$existing" --body \ - "Still failing as of $(date -u +%Y-%m-%d). $RUN_URL" - else - gh issue create \ - --title "Release dry-run is failing" \ - --label "$LABEL" \ - --body "$(printf '%s\n' \ - "The weekly release dry-run failed: $RUN_URL" \ - "" \ - "This checks the release pipeline between releases — Play and App Store credentials, signing certs and provisioning profiles, and that a release build still validates against both stores. A failure here means the next release is likely to fail too, so it's worth fixing before cutting one." \ - "" \ - "Closing this issue is fine once the run is green; a later failure opens a new one.")" - fi From 40733756ab3ad69e48bff2ffc6d22ea67fe18b04 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 13:17:14 -0700 Subject: [PATCH 34/67] build(deps): bump flutter pin to 3.44.9 and upgrade dependencies The pubspec pinned Flutter 3.44.5 while the toolchain had moved to 3.44.9, so `flutter pub upgrade` failed version solving outright and no dependency had been upgradable for a while. Bumping the pin unblocks resolution (CI reads the same field via flutter-version-file). Notable bumps: dio 5.9.2 -> 5.11.0, firebase_core 4.10 -> 4.13 (plus crashlytics/analytics/performance), app_links 7.2.1, flutter_markdown_plus 1.0.12, intl, path_provider, webview_flutter, flutter_cache_manager. dio 5.11 adds DioExceptionType.transformTimeout, which broke the exhaustive switch in BaseApiClient._handleError. Grouped it with the other timeout types and covered all four mappings with tests, which were previously untested. Raised the json_annotation floor to ^4.12.0 to silence a json_serializable build warning; the resolved version was already 4.12.0. flutter_secure_storage stays on v9 deliberately. v10.0.0 shipped in 5.0.0 and was reverted in 5.0.1 (ba28e09) while chasing the #484 startup freeze on low-end Android 14, and upstream #1084 (unrecoverable NPE in initialize() on Android 13) is still open. v9 pins win32 ^5, so device_info_plus, package_info_plus and wakelock_plus are held at their current majors too - their only breaking change was a win32 6.0.0 bump that affects Windows only. Co-Authored-By: Claude Opus 5 --- lib/apis/base_api_client.dart | 5 +- pubspec.lock | 190 +++++++++++++++------------- pubspec.yaml | 12 +- test/apis/base_api_client_test.dart | 35 +++++ 4 files changed, 148 insertions(+), 94 deletions(-) diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index d35e84fc..4fd3745c 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -161,7 +161,8 @@ abstract class BaseApiClient { final exception = switch (error.type) { DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || - DioExceptionType.receiveTimeout => + DioExceptionType.receiveTimeout || + DioExceptionType.transformTimeout => TimeoutException(_getTimeoutMessage(error.type)), DioExceptionType.connectionError => NetworkException( 'No internet connection. Please check your network.', @@ -202,6 +203,8 @@ abstract class BaseApiClient { return 'Upload timeout. Please try again.'; case DioExceptionType.receiveTimeout: return 'Server response timeout. Please try again.'; + case DioExceptionType.transformTimeout: + return 'Response processing timeout. Please try again.'; default: return 'Request timeout. Please try again.'; } diff --git a/pubspec.lock b/pubspec.lock index d8fdc23c..128e2ed0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "0d1f0adfabbab9f46a1a80ce84a4d8b852b6e4dbf53ce413b30e0cf7d3631b71" + sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d" url: "https://pub.dev" source: hosted - version: "1.3.72" + version: "1.3.76" advanced_in_app_review: dependency: "direct main" description: @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: "4ec328cd9fd51fd0e7eb8870a9612c1fde0f091901932238c4f4e60b5f7f1315" + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea url: "https://pub.dev" source: hosted - version: "7.1.2" + version: "7.2.1" app_links_linux: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333" + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" app_links_web: dependency: transitive description: @@ -118,34 +118,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.5" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -158,10 +158,10 @@ packages: dependency: transitive description: name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" url: "https://pub.dev" source: hosted - version: "8.12.6" + version: "8.12.7" cached_network_image: dependency: "direct main" description: @@ -278,10 +278,10 @@ packages: dependency: transitive description: name: dbus - sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.13" + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -302,18 +302,18 @@ packages: dependency: "direct main" description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" url: "https://pub.dev" source: hosted - version: "5.9.2" + version: "5.11.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.1" dismissible_page: dependency: transitive description: @@ -367,90 +367,90 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: "1f68d8a30265149035e9bb0b73962e43f3bf2debef4a7ae2d1d588361d5858a9" + sha256: a139dd0ada1c6e0ffd77bfdb877ac9061ffdec7c415e3e06113bdf8844db771f url: "https://pub.dev" source: hosted - version: "12.4.2" + version: "12.4.6" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "65f97fb923f036d487aa95be99eab90e0f8f636e4783c94deeb52c0e6f5dbae7" + sha256: "448c319ea895da002e43892c7d3f15dccbc3c4c3b81d3e307e37da885ea52bdf" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.6" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: ec49a95c6abaadbd6a4327cc2680d911f86e62fdf6f2c96a3a326603410e2abf + sha256: "67f287a0df75f27eafdd4a66a41e44f232c3aba713ea4794a1159893665a8bf5" url: "https://pub.dev" source: hosted - version: "0.6.1+8" + version: "0.6.1+12" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9 + sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.13.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" + sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "8.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8" + sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.10.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "5d111db2b40426e76e2da95d133c19a9dbcf6500c4e9b3785c68da51fb3f6602" + sha256: "96c1d85de9eddc07061d3f7e2fb75596e75a45c9cec9c2e3a7cc9f97e6f3a748" url: "https://pub.dev" source: hosted - version: "5.2.3" + version: "5.2.7" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "5bf4a83a1b9e0a5218f6d6491227440dd659242a55b16c279de0b8839791539a" + sha256: "47d83b71fd39c580297c696a507a7c2652680ea2045e40c6c4e3c371b0ee7bc6" url: "https://pub.dev" source: hosted - version: "3.8.23" + version: "3.8.27" firebase_performance: dependency: "direct main" description: name: firebase_performance - sha256: a6b88d6933e02e4ba71f0641121dd7024f036b32a9929a01b638b3696a98e9ce + sha256: "570895b372791fca22080b369125fe13f3b6e1682ab0cbc204e2b46acd19b314" url: "https://pub.dev" source: hosted - version: "0.11.4+2" + version: "0.11.4+6" firebase_performance_platform_interface: dependency: transitive description: name: firebase_performance_platform_interface - sha256: "6b9912709d315f4f2e0c87ddada8fe4f4d1b06aa74df52a8ced19fed087f0591" + sha256: "2979eac652173512ac07c45b8ce2ea2e37f16a7f8e4a003a3331405917886d72" url: "https://pub.dev" source: hosted - version: "0.2.0+2" + version: "0.2.0+6" firebase_performance_web: dependency: transitive description: name: firebase_performance_web - sha256: "5ccf0a4511cd477406fe4c02ee2cabee5bd7d3c9e8cc5effdba5dcbd46b90064" + sha256: afb3b87ab2428fa86e4fb092c8069f1d860eb5be12ca5a57795648986093f23d url: "https://pub.dev" source: hosted - version: "0.1.8+8" + version: "0.1.8+12" fixnum: dependency: transitive description: @@ -476,10 +476,10 @@ packages: dependency: "direct main" description: name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.4.2" flutter_colorpicker: dependency: "direct main" description: @@ -508,10 +508,10 @@ packages: dependency: "direct main" description: name: flutter_markdown_plus - sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.12" flutter_mobx: dependency: "direct main" description: @@ -670,10 +670,10 @@ packages: dependency: transitive description: name: image - sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.9.1" + version: "4.8.0" in_app_review: dependency: transitive description: @@ -694,10 +694,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -710,18 +710,26 @@ packages: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -742,10 +750,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.14.0" + version: "6.14.1" leak_tracker: dependency: transitive description: @@ -878,10 +886,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.5.0" octo_image: dependency: transitive description: @@ -934,10 +942,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -958,18 +966,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -1022,10 +1030,10 @@ packages: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" provider: dependency: "direct main" description: @@ -1078,10 +1086,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" url: "https://pub.dev" source: hosted - version: "2.4.26" + version: "2.4.27" shared_preferences_foundation: dependency: transitive description: @@ -1164,18 +1172,18 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.2.4" source_helper: dependency: transitive description: name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.3.12" + version: "1.3.13" source_span: dependency: transitive description: @@ -1212,10 +1220,10 @@ packages: dependency: transitive description: name: sqflite_darwin - sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.3+1" sqflite_platform_interface: dependency: transitive description: @@ -1260,10 +1268,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.4.1+1" term_glyph: dependency: transitive description: @@ -1364,18 +1372,18 @@ packages: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1388,10 +1396,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.2.5" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1420,10 +1428,10 @@ packages: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.6.0" watcher: dependency: transitive description: @@ -1460,10 +1468,10 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4 + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 url: "https://pub.dev" source: hosted - version: "4.14.0" + version: "4.14.1" webview_flutter_android: dependency: "direct main" description: @@ -1516,10 +1524,10 @@ packages: dependency: transitive description: name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "6.6.1" yaml: dependency: transitive description: @@ -1530,4 +1538,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: "3.44.5" + flutter: "3.44.9" diff --git a/pubspec.yaml b/pubspec.yaml index 3bc63b18..a2ec9777 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ environment: sdk: ^3.9.0 # Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via # `flutter-version-file: pubspec.yaml`. Single source of truth: bump here. - flutter: 3.44.5 + flutter: 3.44.9 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -34,12 +34,18 @@ environment: dependencies: flutter: sdk: flutter - json_annotation: ^4.6.0 + json_annotation: ^4.12.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 web_socket_channel: ^3.0.0 + # Pinned to v9 deliberately. v10.0.0 was shipped in 5.0.0 and reverted in + # 5.0.1 (ba28e09) while chasing the #484 startup freeze on low-end Android + # 14; that commit also made deleteAll() non-blocking, so which change fixed + # it was never established. v10 rewrote the Android ciphers, and v11 drops + # the legacy ones entirely, so a 9 -> 11 jump would also strand existing + # tokens. Move to v10 on its own, with low-end Android testing. flutter_secure_storage: ^9.2.2 intl: ^0.20.1 cached_network_image: ^3.1.0 @@ -50,6 +56,8 @@ dependencies: shared_preferences: ^2.0.8 flutter_svg: ^2.0.5 url_launcher: ^6.0.17 + # Held at v9/v12: the v10/v13 majors are just a win32 6.0.0 bump (Windows + # only), which conflicts with flutter_secure_storage v9's win32 ^5. package_info_plus: ^9.0.0 collection: ^1.16.0 flutter_cache_manager: ^3.3.0 diff --git a/test/apis/base_api_client_test.dart b/test/apis/base_api_client_test.dart index 258d68dd..f0170b7d 100644 --- a/test/apis/base_api_client_test.dart +++ b/test/apis/base_api_client_test.dart @@ -331,6 +331,41 @@ void main() { ), ); }); + + const timeoutCases = { + DioExceptionType.connectionTimeout: 'Connection timeout', + DioExceptionType.sendTimeout: 'Upload timeout', + DioExceptionType.receiveTimeout: 'Server response timeout', + DioExceptionType.transformTimeout: 'Response processing timeout', + }; + + timeoutCases.forEach((type, expectedMessage) { + test('${type.name} throws TimeoutException', () async { + final path = '/${type.name}'; + + dioAdapter.onGet( + 'https://api.test.com$path', + (server) => server.throws( + 0, + DioException( + requestOptions: RequestOptions(path: path), + type: type, + ), + ), + ); + + expect( + () => client.testGet(path), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(expectedMessage), + ), + ), + ); + }); + }); }); group('successful get()', () { From 11cd05e6a864b4313b4d4dd19d6568f9d560e3e3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 13:18:10 -0700 Subject: [PATCH 35/67] docs: drop removed build_runner flag from AGENTS.md `--delete-conflicting-outputs` was removed in build_runner 2.15 and is now ignored with a warning; conflicting outputs are handled automatically. Documenting it kept sending agents down a no-op path. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 90e3133c..6ac5b315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,10 @@ flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET # Run with T flutter analyze # Static analysis flutter test # Run tests dart run build_runner build # Regenerate .g.dart files (MobX/JSON models) -dart run build_runner build --delete-conflicting-outputs # Same, but clears stale outputs ``` +`--delete-conflicting-outputs` was removed in build_runner 2.15 and is now ignored — conflicting outputs are handled automatically. + ## Source Structure - `lib/apis/` — API services (all extend `BaseApiClient`) From 9888985debea1c9926eefdcedde0d039d5f78c12 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 14:48:55 -0700 Subject: [PATCH 36/67] fix(auth): list missing Twitch scopes in the re-login dialog Existing tokens lack the newer mod scopes, so Helix 401s looked like a vague permissions failure. Cache scopes from /oauth2/validate and show which ones need re-authorization. Co-authored-by: Cursor --- lib/apis/twitch_api.dart | 17 ++++-- lib/apis/unauthorized_interceptor.dart | 8 ++- lib/screens/settings/stores/auth_store.dart | 40 ++++++++----- lib/utils/twitch_oauth_scopes.dart | 60 +++++++++++++++++++ scripts/get_twitch_token.dart | 21 ++++--- test/apis/twitch_api_test.dart | 12 ++-- test/utils/twitch_oauth_scopes_test.dart | 66 +++++++++++++++++++++ 7 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 lib/utils/twitch_oauth_scopes.dart create mode 100644 test/utils/twitch_oauth_scopes_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 60401ebe..86015cf4 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -145,17 +145,24 @@ class TwitchApi extends BaseApiClient { return data['access_token'] as String; } - /// Returns a bool indicating the validity of the given token. - Future validateToken({required String token}) async { + /// Validates [token] with Twitch. + /// + /// Returns the granted OAuth scopes when the token is valid, or `null` when + /// it is invalid/expired. App-access tokens may return an empty list. + Future?> validateToken({required String token}) async { try { - await get( + final data = await get( '$_oauthBaseUrl/validate', headers: {'Authorization': 'Bearer $token'}, ); - return true; + final scopes = data['scopes']; + if (scopes is List) { + return scopes.map((scope) => scope.toString()).toList(); + } + return const []; } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) - return false; + return null; } on ApiException catch (e) { // Network/timeout/server errors -> treat as indeterminate, not invalid debugPrint('Token validation indeterminate: $e'); diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index bd983fc4..0241a1e6 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; @@ -45,9 +46,10 @@ class UnauthorizedInterceptor extends Interceptor { // Determine if user is logged in but missing scopes vs completely logged out final isLoggedIn = _authStore.isLoggedIn; final title = isLoggedIn ? 'Missing permissions' : 'Session expired'; - final message = isLoggedIn - ? 'Your session is missing permissions. Please log in again to continue.' - : 'Your session has expired. Please log in again to continue.'; + final message = unauthorizedDialogMessage( + isLoggedIn: isLoggedIn, + grantedScopes: _authStore.grantedScopes, + ); showDialog( context: context, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 88ec669a..20dd25bc 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -10,6 +10,7 @@ import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:frosty/services/cookie_extractor.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -34,9 +35,6 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. final TwitchApi twitchApi; - /// Whether the token is valid or not. - var _tokenIsValid = false; - /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -61,6 +59,13 @@ abstract class AuthBase with Store { @readonly var _isLoggedIn = false; + /// OAuth scopes granted to the current user token. + /// + /// Used to explain missing-permission 401s without another network round-trip. + var _grantedScopes = const []; + + List get grantedScopes => _grantedScopes; + /// Authentication headers for Twitch API requests. @computed Map get headersTwitch => { @@ -170,8 +175,7 @@ abstract class AuthBase with Store { 'client_id': clientId, 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', - 'scope': - 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color user:read:moderated_channels moderator:manage:chat_messages moderator:manage:banned_users', + 'scope': twitchUserScopeQuery, 'force_verify': 'true', }, ), @@ -257,23 +261,27 @@ abstract class AuthBase with Store { // Retrieve the currently stored default token if it exists. _token = await _storage.read(key: _defaultTokenKey); // If the token does not exist or is invalid, get a new token and store it. - if (_token == null || !await twitchApi.validateToken(token: _token!)) { + if (_token == null || + await twitchApi.validateToken(token: _token!) == null) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } } else { // Validate the existing token. If it fails, start reconnect loop. + final List scopes; try { - _tokenIsValid = await twitchApi.validateToken(token: _token!); + final validated = await twitchApi.validateToken(token: _token!); + if (validated == null) return await logout(); + scopes = validated; } on ApiException catch (e) { debugPrint('Token validation failed: $e'); _isLoggedIn = false; + _grantedScopes = const []; _startReconnectLoop(); return; } - // If the token is invalid, logout. - if (!_tokenIsValid) return await logout(); + _grantedScopes = scopes; // Initialize the user store. await user.init(); @@ -301,8 +309,9 @@ abstract class AuthBase with Store { Future login({required String token}) async { try { // Validate the custom token. - _tokenIsValid = await twitchApi.validateToken(token: token); - if (!_tokenIsValid) return; + final scopes = await twitchApi.validateToken(token: token); + if (scopes == null) return; + _grantedScopes = scopes; // Replace the current default token with the new custom token. _token = token; @@ -335,6 +344,7 @@ abstract class AuthBase with Store { await _storage.delete(key: _gqlTokenKey); _token = null; _gqlToken = null; + _grantedScopes = const []; // Clear the user info. user.dispose(); @@ -343,7 +353,8 @@ abstract class AuthBase with Store { _token = await _storage.read(key: _defaultTokenKey); // If the default token does not already exist or it's invalid, get the new default token and store it. - if (_token == null || !await twitchApi.validateToken(token: _token!)) { + if (_token == null || + await twitchApi.validateToken(token: _token!) == null) { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } @@ -379,14 +390,15 @@ abstract class AuthBase with Store { return; } - final isValid = await twitchApi.validateToken(token: stored); - if (!isValid) { + final scopes = await twitchApi.validateToken(token: stored); + if (scopes == null) { await logout(); return; } // Token valid again — restore session. _token = stored; + _grantedScopes = scopes; await user.init(); if (user.details != null) { _isLoggedIn = true; diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart new file mode 100644 index 00000000..21a649b5 --- /dev/null +++ b/lib/utils/twitch_oauth_scopes.dart @@ -0,0 +1,60 @@ +/// OAuth scopes Frosty requests for a logged-in Twitch user session. +const twitchUserScopes = [ + 'chat:read', + 'chat:edit', + 'user:read:follows', + 'user:read:blocked_users', + 'user:manage:blocked_users', + 'user:manage:chat_color', + 'user:read:moderated_channels', + 'moderator:manage:chat_messages', + 'moderator:manage:banned_users', +]; + +/// Space-separated scope string for Twitch's authorize URL. +String get twitchUserScopeQuery => twitchUserScopes.join(' '); + +/// Human-readable labels for [twitchUserScopes]. +const twitchScopeLabels = { + 'chat:read': 'Read chat messages', + 'chat:edit': 'Send chat messages', + 'user:read:follows': 'View followed channels', + 'user:read:blocked_users': 'View blocked users', + 'user:manage:blocked_users': 'Block and unblock users', + 'user:manage:chat_color': 'Change chat name color', + 'user:read:moderated_channels': 'View channels you moderate', + 'moderator:manage:chat_messages': 'Delete chat messages as a moderator', + 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', +}; + +/// Scopes in [required] that are not present in [granted]. +List missingTwitchScopes({ + required Iterable granted, + Iterable required = twitchUserScopes, +}) { + final grantedSet = granted.toSet(); + return required.where((scope) => !grantedSet.contains(scope)).toList(); +} + +/// Dialog body for a Twitch Helix/OAuth 401. +String unauthorizedDialogMessage({ + required bool isLoggedIn, + required Iterable grantedScopes, +}) { + if (!isLoggedIn) { + return 'Your session has expired. Please log in again to continue.'; + } + + final missing = missingTwitchScopes(granted: grantedScopes); + if (missing.isEmpty) { + return 'Your session is missing permissions. Please log in again to continue.'; + } + + final bullets = missing + .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') + .join('\n'); + + return 'Frosty needs additional Twitch permissions:\n\n' + '$bullets\n\n' + 'Log in again to grant them.'; +} diff --git a/scripts/get_twitch_token.dart b/scripts/get_twitch_token.dart index 4a960b79..5605f378 100644 --- a/scripts/get_twitch_token.dart +++ b/scripts/get_twitch_token.dart @@ -8,9 +8,8 @@ // Then long-press the Anonymous account tile in the app to log in. import 'dart:io'; -const scopes = - 'chat:read+chat:edit+user:read:follows+user:read:blocked_users+' - 'user:manage:blocked_users+user:manage:chat_color'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; + const redirectUri = 'https://twitch.tv/login'; void main() async { @@ -24,11 +23,17 @@ void main() async { exit(1); } - final authorizeUrl = 'https://id.twitch.tv/oauth2/authorize' - '?client_id=$clientId' - '&redirect_uri=$redirectUri' - '&response_type=token' - '&scope=$scopes'; + final authorizeUrl = Uri( + scheme: 'https', + host: 'id.twitch.tv', + path: '/oauth2/authorize', + queryParameters: { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'response_type': 'token', + 'scope': twitchUserScopeQuery, + }, + ).toString(); stdout.writeln('Opening Twitch authorization in your browser...'); await _openUrl(authorizeUrl); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index c6b2d413..e1a18f52 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -215,26 +215,26 @@ void main() { }); group('validateToken', () { - test('returns true for valid token', () async { + test('returns granted scopes for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); - final isValid = await api.validateToken(token: 'valid_token'); + final scopes = await api.validateToken(token: 'valid_token'); - expect(isValid, isTrue); + expect(scopes, ['chat:read', 'chat:edit']); }); - test('returns false for expired token (401)', () async { + test('returns null for expired token (401)', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(401, {'message': 'invalid access token'}), ); - final isValid = await api.validateToken(token: 'expired_token'); + final scopes = await api.validateToken(token: 'expired_token'); - expect(isValid, isFalse); + expect(scopes, isNull); }); test('rethrows on server error', () { diff --git a/test/utils/twitch_oauth_scopes_test.dart b/test/utils/twitch_oauth_scopes_test.dart new file mode 100644 index 00000000..797c5d2e --- /dev/null +++ b/test/utils/twitch_oauth_scopes_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart'; + +void main() { + group('missingTwitchScopes', () { + test('returns scopes not present on the token', () { + final missing = missingTwitchScopes( + granted: const ['chat:read', 'chat:edit'], + ); + + expect(missing, contains('user:read:moderated_channels')); + expect(missing, contains('moderator:manage:chat_messages')); + expect(missing, contains('moderator:manage:banned_users')); + expect(missing, isNot(contains('chat:read'))); + }); + + test('returns empty when all required scopes are granted', () { + expect( + missingTwitchScopes(granted: twitchUserScopes), + isEmpty, + ); + }); + }); + + group('unauthorizedDialogMessage', () { + test('explains expired session when logged out', () { + expect( + unauthorizedDialogMessage( + isLoggedIn: false, + grantedScopes: const [], + ), + contains('expired'), + ); + }); + + test('lists human-readable missing permissions when logged in', () { + final message = unauthorizedDialogMessage( + isLoggedIn: true, + grantedScopes: const [ + 'chat:read', + 'chat:edit', + 'user:read:follows', + 'user:read:blocked_users', + 'user:manage:blocked_users', + 'user:manage:chat_color', + ], + ); + + expect(message, contains('additional Twitch permissions')); + expect(message, contains('View channels you moderate')); + expect(message, contains('Delete chat messages as a moderator')); + expect(message, contains('Timeout and ban users as a moderator')); + expect(message, isNot(contains('Send chat messages'))); + }); + + test('falls back when logged in but no missing scopes are known', () { + expect( + unauthorizedDialogMessage( + isLoggedIn: true, + grantedScopes: twitchUserScopes, + ), + contains('missing permissions'), + ); + }); + }); +} From 2d65aeec2907d29bbe274116829fd5d28101a14a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 15:18:06 -0700 Subject: [PATCH 37/67] fix(settings): limit web-session dialog to the profile status icon Tapping the display name or surrounding whitespace was opening the web-session dialog; only the checkmark/info icon should. Co-authored-by: Cursor --- .../account/widgets/profile_card.dart | 115 ++++++++++-------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index b9c5efa7..a91593f4 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -46,62 +46,71 @@ class ProfileCard extends StatelessWidget { userLogin: authStore.user.details!.login, radius: 12, ), - title: GestureDetector( - onTap: () { - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Web session', - message: hasToken - ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' - : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', - actions: hasToken - ? [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('OK'), - ), - ] - : [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - navigator.push( - MaterialPageRoute( - builder: (context) => LoginWebView(), - ), - ); - }, - child: const Text('Log in'), - ), - ], - ), - ); - }, - child: Text.rich( - TextSpan( - text: authStore.user.details!.displayName, - children: [ - const WidgetSpan(child: SizedBox(width: 6)), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - hasToken - ? Icons.check_circle_outline_rounded - : Icons.info_outline_rounded, - size: 16, - color: hasToken ? Colors.green : Colors.amber, + title: Text.rich( + TextSpan( + text: authStore.user.details!.displayName, + children: [ + const WidgetSpan(child: SizedBox(width: 6)), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Web session', + message: hasToken + ? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.' + : 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.', + actions: hasToken + ? [ + TextButton( + onPressed: + Navigator.of(dialogContext).pop, + child: const Text('OK'), + ), + ] + : [ + TextButton( + onPressed: + Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + navigator.push( + MaterialPageRoute( + builder: (context) => + LoginWebView(), + ), + ); + }, + child: const Text('Log in'), + ), + ], + ), + ); + }, + // Padding enlarges the hit target without stealing taps + // on the display name / surrounding ListTile. + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + hasToken + ? Icons.check_circle_outline_rounded + : Icons.info_outline_rounded, + size: 16, + color: hasToken ? Colors.green : Colors.amber, + ), ), ), - ], - ), - overflow: TextOverflow.ellipsis, + ), + ], ), + overflow: TextOverflow.ellipsis, ), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => _showAccountOptionsModalBottomSheet(context), From cc772a7c901a74f64bbc4a5852d6745e4e9ea14e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 15:33:06 -0700 Subject: [PATCH 38/67] fix(auth): request moderator OAuth scopes only on opt-in Stop asking for mod scopes at normal login so existing core-only tokens never hit a missing-permissions dialog. Gate moderated-channels Helix, offer an Enable flow for mods/broadcasters, and upgrade atomically. Co-authored-by: Cursor --- lib/apis/twitch_api.dart | 33 ++- lib/apis/unauthorized_interceptor.dart | 98 +++++--- lib/models/irc.dart | 5 +- .../chat/widgets/moderation_actions.dart | 66 ++++- lib/screens/onboarding/login_webview.dart | 32 ++- lib/screens/settings/stores/auth_store.dart | 225 +++++++++++++++--- lib/screens/settings/stores/auth_store.g.dart | 41 ++++ lib/screens/settings/stores/user_store.dart | 51 ++-- lib/utils/twitch_oauth_scopes.dart | 54 ++++- scripts/get_twitch_token.dart | 2 + test/apis/twitch_api_test.dart | 11 +- .../unauthorized_interceptor_paths_test.dart | 20 ++ test/models/irc_test.dart | 13 + .../settings/stores/user_store_test.dart | 99 ++++++++ test/utils/twitch_oauth_scopes_test.dart | 68 ++++-- 15 files changed, 690 insertions(+), 128 deletions(-) create mode 100644 test/apis/unauthorized_interceptor_paths_test.dart create mode 100644 test/screens/settings/stores/user_store_test.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 86015cf4..148b7efa 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -147,19 +147,24 @@ class TwitchApi extends BaseApiClient { /// Validates [token] with Twitch. /// - /// Returns the granted OAuth scopes when the token is valid, or `null` when - /// it is invalid/expired. App-access tokens may return an empty list. - Future?> validateToken({required String token}) async { + /// Returns token info (scopes + optional `user_id`) when valid, or `null` + /// when invalid/expired. App-access tokens may omit `user_id` and return an + /// empty scope list. + Future validateToken({required String token}) async { try { final data = await get( '$_oauthBaseUrl/validate', headers: {'Authorization': 'Bearer $token'}, ); final scopes = data['scopes']; - if (scopes is List) { - return scopes.map((scope) => scope.toString()).toList(); - } - return const []; + final scopeList = scopes is List + ? scopes.map((scope) => scope.toString()).toList() + : const []; + final userId = data['user_id']?.toString(); + return TwitchTokenInfo( + scopes: scopeList, + userId: userId == null || userId.isEmpty ? null : userId, + ); } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) return null; @@ -508,8 +513,8 @@ class TwitchApi extends BaseApiClient { return result; } on ApiException catch (e) { - // Existing users may not have re-authenticated with the new scope yet. - // Treat this as non-fatal and hide moderation actions. + // Backstop: callers should skip this without moderator scopes, but a + // stale/partial token must not take down chat. debugPrint('Failed to get moderated channels: $e'); return []; } @@ -590,3 +595,13 @@ class TwitchApi extends BaseApiClient { return true; } } + +/// Result of a successful Twitch `/oauth2/validate` call. +class TwitchTokenInfo { + final List scopes; + + /// Present for user access tokens; omitted for app access tokens. + final String? userId; + + const TwitchTokenInfo({required this.scopes, this.userId}); +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart index 0241a1e6..318a04ce 100644 --- a/lib/apis/unauthorized_interceptor.dart +++ b/lib/apis/unauthorized_interceptor.dart @@ -24,8 +24,57 @@ class UnauthorizedInterceptor extends Interceptor { return; } - // For other requests, show login dialog and reject so the Future completes - _showLoginDialog(); + final path = err.requestOptions.uri.path; + + // Background moderated-channels fetches must never nag — especially for + // core-only tokens after an app upgrade. + if (isTwitchModeratedChannelsPath(path)) { + handler.reject(err); + return; + } + + // Moderator actions without scopes → opt-in Enable flow, not a generic + // "missing permissions" re-login for core scopes. + if (isTwitchModeratorActionPath(path) && + !_authStore.hasModeratorScopes) { + _showAuthDialog( + title: 'Enable moderator tools', + message: enableModeratorToolsDialogMessage(), + primaryLabel: 'Enable', + onPrimary: () => openEnableModeratorTools(_authStore), + ); + handler.reject(err); + return; + } + + // Core-only messaging: if core scopes are present, prefer "Session expired" + // over "Missing permissions" (401 ≠ always missing scope). + final isLoggedIn = _authStore.isLoggedIn; + final missingCore = missingTwitchScopes( + granted: _authStore.grantedScopes, + ); + _showAuthDialog( + title: isLoggedIn && missingCore.isNotEmpty + ? 'Missing permissions' + : 'Session expired', + message: unauthorizedDialogMessage( + isLoggedIn: isLoggedIn, + grantedScopes: _authStore.grantedScopes, + ), + primaryLabel: 'Log in', + onPrimary: () { + final context = navigatorKey.currentContext; + if (context == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: _authStore, + child: const LoginWebView(), + ), + ), + ); + }, + ); handler.reject(err); return; } @@ -34,26 +83,20 @@ class UnauthorizedInterceptor extends Interceptor { handler.next(err); } - void _showLoginDialog() { - // Use the global navigator key to show dialog without needing BuildContext + void _showAuthDialog({ + required String title, + required String message, + required String primaryLabel, + required VoidCallback onPrimary, + }) { final context = navigatorKey.currentContext; if (context == null) return; - - // Use a flag to prevent multiple dialogs if (_isDialogShowing) return; _isDialogShowing = true; - // Determine if user is logged in but missing scopes vs completely logged out - final isLoggedIn = _authStore.isLoggedIn; - final title = isLoggedIn ? 'Missing permissions' : 'Session expired'; - final message = unauthorizedDialogMessage( - isLoggedIn: isLoggedIn, - grantedScopes: _authStore.grantedScopes, - ); - showDialog( context: context, - barrierDismissible: false, // User must choose an action + barrierDismissible: false, builder: (BuildContext dialogContext) { return FrostyDialog( title: title, @@ -62,31 +105,22 @@ class UnauthorizedInterceptor extends Interceptor { TextButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); // Close dialog + Navigator.of(dialogContext).pop(); }, child: const Text('Cancel'), ), FilledButton( onPressed: () { _isDialogShowing = false; - Navigator.of(dialogContext).pop(); // Close dialog - // Navigate to login WebView - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Provider.value( - value: _authStore, - child: const LoginWebView(), - ), - ), - ); + Navigator.of(dialogContext).pop(); + onPrimary(); }, - child: Text('Log in'), + child: Text(primaryLabel), ), ], ); }, ).then((_) { - // Reset flag when dialog is dismissed _isDialogShowing = false; }); } @@ -99,3 +133,11 @@ class UnauthorizedInterceptor extends Interceptor { url.startsWith('https://id.twitch.tv/oauth2'); } } + +/// Helix path for listing channels the user moderates. +bool isTwitchModeratedChannelsPath(String path) => + path.contains('/moderation/channels'); + +/// Helix paths for delete / ban / timeout / unban. +bool isTwitchModeratorActionPath(String path) => + path.contains('/moderation/chat') || path.contains('/moderation/bans'); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e7f7ce78..54c9fb23 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1304,8 +1304,9 @@ class USERSTATE { raw: ircMessage.raw, color: ircMessage.tags['color'] ?? color, displayName: ircMessage.tags['display-name'] ?? displayName, - mod: ircMessage.tags['mod'] == '0' ? false : true, - subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, + // Missing tags must be false — the old `!= '0'` default treated absence as mod. + mod: ircMessage.tags['mod'] == '1', + subscriber: ircMessage.tags['subscriber'] == '1', ); } diff --git a/lib/screens/channel/chat/widgets/moderation_actions.dart b/lib/screens/channel/chat/widgets/moderation_actions.dart index 2062be4b..390100c6 100644 --- a/lib/screens/channel/chat/widgets/moderation_actions.dart +++ b/lib/screens/channel/chat/widgets/moderation_actions.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; /// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], /// reused by the message long-press menu and the user panel. /// -/// Renders nothing unless the viewer can moderate the channel. [messageId] -/// enables the per-message Delete action; omit it (e.g. the user panel, which -/// isn't tied to one message) to hide Delete. The unban action only appears -/// when the user is currently banned/timed-out (seen via CLEARCHAT). +/// Renders nothing unless the viewer can moderate the channel, or should see +/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes. +/// [messageId] enables the per-message Delete action; omit it (e.g. the user +/// panel, which isn't tied to one message) to hide Delete. The unban action +/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT). class ModerationActions extends StatelessWidget { final ChatStore chatStore; final String targetUserId; @@ -29,10 +33,54 @@ class ModerationActions extends StatelessWidget { @override Widget build(BuildContext context) { - if (!chatStore.auth.user.canModerate(chatStore.channelId)) { - return const SizedBox.shrink(); - } + return Observer( + builder: (context) { + final auth = chatStore.auth; + final hasModScopes = auth.hasModeratorScopes; + if (hasModScopes) { + if (auth.user.canPerformModeratorActions(chatStore.channelId)) { + return _actionsColumn(context); + } + return const SizedBox.shrink(); + } + + // IRC/broadcaster is a CTA hint only — not authority to call Helix. + // Read userState.mod only on the no-scopes path so USERSTATE acks do + // not rebuild this sheet while real actions are already available. + final isBroadcaster = auth.user.details?.id == chatStore.channelId; + final ircModHint = chatStore.userState.mod; + if (isBroadcaster || ircModHint) { + return _enableModeratorToolsRow(context, auth); + } + + return const SizedBox.shrink(); + }, + ); + } + + Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + ListTile( + onTap: () { + Navigator.pop(context); + // Use the root navigator after closing the sheet. + openEnableModeratorTools(auth); + }, + leading: const Icon(Icons.shield_outlined), + title: const Text('Enable moderator tools…'), + subtitle: const Text( + 'Authorize Twitch permissions to delete, timeout, and ban.', + ), + ), + ], + ); + } + + Widget _actionsColumn(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -133,7 +181,9 @@ class ModerationActions extends StatelessWidget { duration: 600, // 10 minutes ); chatStore.updateNotification( - success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user', + success + ? '$targetName timed out for 10 minutes.' + : 'Failed to timeout user', ); } diff --git a/lib/screens/onboarding/login_webview.dart b/lib/screens/onboarding/login_webview.dart index 360346be..b9096e8c 100644 --- a/lib/screens/onboarding/login_webview.dart +++ b/lib/screens/onboarding/login_webview.dart @@ -1,16 +1,39 @@ import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/widgets/frosty_app_bar.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; +/// Pushes the atomic moderator-scopes upgrade WebView. +void openEnableModeratorTools(AuthStore authStore, {BuildContext? context}) { + final navContext = context ?? navigatorKey.currentContext; + if (navContext == null) return; + Navigator.of(navContext).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: authStore, + child: const LoginWebView(upgradeModeratorScopes: true), + ), + ), + ); +} + /// Reusable WebView widget for Twitch OAuth login flow class LoginWebView extends StatelessWidget { /// Optional widget to navigate to after successful login final Widget? routeAfter; - const LoginWebView({super.key, this.routeAfter}); + /// When true, requests core + moderator scopes and runs the atomic upgrade + /// commit instead of a full [AuthStore.login]. + final bool upgradeModeratorScopes; + + const LoginWebView({ + super.key, + this.routeAfter, + this.upgradeModeratorScopes = false, + }); @override Widget build(BuildContext context) { @@ -18,7 +41,11 @@ class LoginWebView extends StatelessWidget { return Scaffold( appBar: FrostyAppBar( - title: const Text('Connect with Twitch'), + title: Text( + upgradeModeratorScopes + ? 'Enable moderator tools' + : 'Connect with Twitch', + ), actions: [ IconButton( icon: const Icon(Icons.help_rounded), @@ -44,6 +71,7 @@ class LoginWebView extends StatelessWidget { body: WebViewWidget( controller: authStore.createAuthWebViewController( routeAfter: routeAfter, + upgradeModeratorScopes: upgradeModeratorScopes, ), ), ); diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index 20dd25bc..b0c36094 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -10,7 +10,7 @@ import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:frosty/services/cookie_extractor.dart'; -import 'package:frosty/utils/twitch_oauth_scopes.dart'; +import 'package:frosty/utils/twitch_oauth_scopes.dart' as twitch_scopes; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:mobx/mobx.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -32,6 +32,12 @@ abstract class AuthBase with Store { /// The secure storage key for the GQL web cookie token. static const _gqlTokenKey = 'gql_token'; + /// Twitch user id that opted into moderator scopes on this device. + static const _moderatorOptInUserIdKey = 'moderator_scopes_opt_in_user_id'; + + /// Last logged-in Twitch user id (kept across logout for re-login scope choice). + static const _lastUserIdKey = 'last_user_id'; + /// The Twitch API service for making requests. final TwitchApi twitchApi; @@ -45,7 +51,7 @@ abstract class AuthBase with Store { static const _maxReconnectAttempts = 5; /// The MobX store containing information relevant to the current user. - final UserStore user; + late final UserStore user; /// The user token used to authenticate with the Twitch API. @readonly @@ -60,11 +66,19 @@ abstract class AuthBase with Store { var _isLoggedIn = false; /// OAuth scopes granted to the current user token. - /// - /// Used to explain missing-permission 401s without another network round-trip. - var _grantedScopes = const []; + @readonly + var _grantedScopes = ObservableList(); - List get grantedScopes => _grantedScopes; + /// Twitch user id that previously enabled moderator tools on this device. + String? _moderatorOptInUserId; + + /// Last known Twitch user id, retained after logout for scope selection. + String? _lastUserId; + + /// Whether the current token includes every moderator scope Frosty needs. + @computed + bool get hasModeratorScopes => + twitch_scopes.hasModeratorScopes(_grantedScopes); /// Authentication headers for Twitch API requests. @computed @@ -81,6 +95,7 @@ abstract class AuthBase with Store { FutureOr handleNavigation({ required NavigationRequest request, Widget? routeAfter, + bool upgradeModeratorScopes = false, }) { // Check if the URL is the redirect URI. if (request.url.startsWith('https://twitch.tv/login')) { @@ -88,8 +103,13 @@ abstract class AuthBase with Store { final uri = Uri.parse(request.url.replaceFirst('#', '?')); final token = uri.queryParameters['access_token']; - // Login with the provided token. - if (token != null) login(token: token); + if (token != null) { + if (upgradeModeratorScopes) { + unawaited(completeModeratorUpgrade(token: token)); + } else { + unawaited(login(token: token)); + } + } } // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". @@ -97,6 +117,13 @@ abstract class AuthBase with Store { // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { _extractGqlToken(); + + // Upgrade WebViews pop themselves after an atomic commit so we never + // dismiss into a half-applied session. + if (upgradeModeratorScopes) { + return NavigationDecision.navigate; + } + if (routeAfter != null) { navigatorKey.currentState?.pop(); navigatorKey.currentState?.push( @@ -112,7 +139,10 @@ abstract class AuthBase with Store { return NavigationDecision.navigate; } - WebViewController createAuthWebViewController({Widget? routeAfter}) { + WebViewController createAuthWebViewController({ + Widget? routeAfter, + bool upgradeModeratorScopes = false, + }) { final webViewController = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) // Use platform-specific user agents to allow Google OAuth sign-in. @@ -124,11 +154,18 @@ abstract class AuthBase with Store { : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', ); + final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes + ? twitch_scopes.twitchAllUserScopeQuery + : twitch_scopes.twitchUserScopeQuery; + return webViewController ..setNavigationDelegate( NavigationDelegate( - onNavigationRequest: (request) => - handleNavigation(request: request, routeAfter: routeAfter), + onNavigationRequest: (request) => handleNavigation( + request: request, + routeAfter: routeAfter, + upgradeModeratorScopes: upgradeModeratorScopes, + ), onWebResourceError: (error) { debugPrint('Auth WebView error: ${error.description}'); }, @@ -175,7 +212,7 @@ abstract class AuthBase with Store { 'client_id': clientId, 'redirect_uri': 'https://twitch.tv/login', 'response_type': 'token', - 'scope': twitchUserScopeQuery, + 'scope': scopeQuery, 'force_verify': 'true', }, ), @@ -222,7 +259,26 @@ abstract class AuthBase with Store { ); } - AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); + AuthBase({required this.twitchApi}) { + user = UserStore( + twitchApi: twitchApi, + hasModeratorScopes: () => hasModeratorScopes, + ); + } + + bool get _shouldIncludeModeratorScopes { + final currentId = user.details?.id; + if (currentId != null && currentId == _moderatorOptInUserId) { + return true; + } + // After logout, still request mod scopes for the same opted-in account. + if (currentId == null && + _lastUserId != null && + _lastUserId == _moderatorOptInUserId) { + return true; + } + return false; + } Future _extractGqlToken() async { try { @@ -247,13 +303,40 @@ abstract class AuthBase with Store { await _storage.delete(key: _gqlTokenKey); } + void _setGrantedScopes(List scopes) { + _grantedScopes = ObservableList.of(scopes); + } + + Future _persistModeratorOptIn(String userId) async { + if (_moderatorOptInUserId == userId) return; + _moderatorOptInUserId = userId; + await _storage.write(key: _moderatorOptInUserIdKey, value: userId); + } + + Future _persistLastUserId(String? userId) async { + if (_lastUserId == userId) return; + _lastUserId = userId; + if (userId == null) { + await _storage.delete(key: _lastUserIdKey); + } else { + await _storage.write(key: _lastUserIdKey, value: userId); + } + } + /// Initialize by retrieving a token if it does not already exist. @action Future init() async { try { - // Read and set the currently stored user token, if any. - _token = await _storage.read(key: _userTokenKey); - _gqlToken = await _storage.read(key: _gqlTokenKey); + final stored = await Future.wait([ + _storage.read(key: _moderatorOptInUserIdKey), + _storage.read(key: _lastUserIdKey), + _storage.read(key: _userTokenKey), + _storage.read(key: _gqlTokenKey), + ]); + _moderatorOptInUserId = stored[0]; + _lastUserId = stored[1]; + _token = stored[2]; + _gqlToken = stored[3]; // If the token does not exist, get the default token. // Otherwise, log in. @@ -266,28 +349,30 @@ abstract class AuthBase with Store { _token = await twitchApi.getDefaultToken(); await _storage.write(key: _defaultTokenKey, value: _token); } + _setGrantedScopes(const []); } else { // Validate the existing token. If it fails, start reconnect loop. - final List scopes; + final TwitchTokenInfo info; try { final validated = await twitchApi.validateToken(token: _token!); if (validated == null) return await logout(); - scopes = validated; + info = validated; } on ApiException catch (e) { debugPrint('Token validation failed: $e'); _isLoggedIn = false; - _grantedScopes = const []; + _setGrantedScopes(const []); _startReconnectLoop(); return; } - _grantedScopes = scopes; + _setGrantedScopes(info.scopes); // Initialize the user store. await user.init(); if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); _stopReconnectLoop(); // Recover a missed web session link — the WebView cookie store may @@ -309,9 +394,9 @@ abstract class AuthBase with Store { Future login({required String token}) async { try { // Validate the custom token. - final scopes = await twitchApi.validateToken(token: token); - if (scopes == null) return; - _grantedScopes = scopes; + final info = await twitchApi.validateToken(token: token); + if (info == null) return; + _setGrantedScopes(info.scopes); // Replace the current default token with the new custom token. _token = token; @@ -325,6 +410,12 @@ abstract class AuthBase with Store { // Set the login status to logged in. if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); + // A fresh login that already includes mod scopes (re-auth after opt-in) + // should keep the opt-in flag for this account. + if (hasModeratorScopes) { + await _persistModeratorOptIn(user.details!.id); + } FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true); FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id); _stopReconnectLoop(); @@ -334,17 +425,96 @@ abstract class AuthBase with Store { } } + /// Atomically upgrades the current session with moderator OAuth scopes. + /// + /// Keeps the existing token until validation proves the new token is for the + /// same user and includes every moderator scope. On any failure, the old + /// session is left untouched. + @action + Future completeModeratorUpgrade({required String token}) async { + final currentUserId = user.details?.id; + if (currentUserId == null || !_isLoggedIn) { + _showModeratorUpgradeFailed( + 'Log in first, then enable moderator tools from a channel you moderate.', + ); + return false; + } + + try { + final info = await twitchApi.validateToken(token: token); + if (info == null) { + _showModeratorUpgradeFailed( + 'Could not validate the new Twitch permissions. Your current session was kept.', + ); + return false; + } + + if (info.userId != currentUserId) { + _showModeratorUpgradeFailed( + 'Log in with the same Twitch account to enable moderator tools. Your current session was kept.', + ); + return false; + } + + if (!twitch_scopes.hasModeratorScopes(info.scopes)) { + _showModeratorUpgradeFailed( + 'Moderator permissions were not granted. Your current session was kept.', + ); + return false; + } + + // Commit only after all gates pass. + _token = token; + _setGrantedScopes(info.scopes); + await Future.wait([ + _storage.write(key: _userTokenKey, value: token), + _persistModeratorOptIn(currentUserId), + ]); + await user.refreshModeratedChannels(); + + navigatorKey.currentState?.pop(); + return true; + } catch (e) { + debugPrint('Moderator upgrade failed due to $e'); + _showModeratorUpgradeFailed( + 'Could not enable moderator tools. Your current session was kept.', + ); + return false; + } + } + + void _showModeratorUpgradeFailed(String message) { + final context = navigatorKey.currentContext; + if (context == null) return; + showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Moderator tools not enabled', + message: message, + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('OK'), + ), + ], + ), + ); + } + /// Logs out the current user and updates fields accordingly. @action Future logout() async { try { _stopReconnectLoop(); + final previousUserId = user.details?.id ?? _lastUserId; + await _persistLastUserId(previousUserId); + // Delete the existing user token and GQL token. await _storage.delete(key: _userTokenKey); await _storage.delete(key: _gqlTokenKey); _token = null; _gqlToken = null; - _grantedScopes = const []; + _setGrantedScopes(const []); // Clear the user info. user.dispose(); @@ -390,18 +560,19 @@ abstract class AuthBase with Store { return; } - final scopes = await twitchApi.validateToken(token: stored); - if (scopes == null) { + final info = await twitchApi.validateToken(token: stored); + if (info == null) { await logout(); return; } // Token valid again — restore session. _token = stored; - _grantedScopes = scopes; + _setGrantedScopes(info.scopes); await user.init(); if (user.details != null) { _isLoggedIn = true; + await _persistLastUserId(user.details!.id); _error = null; _stopReconnectLoop(); } diff --git a/lib/screens/settings/stores/auth_store.g.dart b/lib/screens/settings/stores/auth_store.g.dart index 1b49807f..d50e1ed4 100644 --- a/lib/screens/settings/stores/auth_store.g.dart +++ b/lib/screens/settings/stores/auth_store.g.dart @@ -9,6 +9,14 @@ part of 'auth_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$AuthStore on AuthBase, Store { + Computed? _$hasModeratorScopesComputed; + + @override + bool get hasModeratorScopes => + (_$hasModeratorScopesComputed ??= Computed( + () => super.hasModeratorScopes, + name: 'AuthBase.hasModeratorScopes', + )).value; Computed>? _$headersTwitchComputed; @override @@ -75,6 +83,26 @@ mixin _$AuthStore on AuthBase, Store { }); } + late final _$_grantedScopesAtom = Atom( + name: 'AuthBase._grantedScopes', + context: context, + ); + + ObservableList get grantedScopes { + _$_grantedScopesAtom.reportRead(); + return super._grantedScopes; + } + + @override + ObservableList get _grantedScopes => grantedScopes; + + @override + set _grantedScopes(ObservableList value) { + _$_grantedScopesAtom.reportWrite(value, super._grantedScopes, () { + super._grantedScopes = value; + }); + } + late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context); String? get error { @@ -121,6 +149,18 @@ mixin _$AuthStore on AuthBase, Store { return _$loginAsyncAction.run(() => super.login(token: token)); } + late final _$completeModeratorUpgradeAsyncAction = AsyncAction( + 'AuthBase.completeModeratorUpgrade', + context: context, + ); + + @override + Future completeModeratorUpgrade({required String token}) { + return _$completeModeratorUpgradeAsyncAction.run( + () => super.completeModeratorUpgrade(token: token), + ); + } + late final _$logoutAsyncAction = AsyncAction( 'AuthBase.logout', context: context, @@ -134,6 +174,7 @@ mixin _$AuthStore on AuthBase, Store { @override String toString() { return ''' +hasModeratorScopes: ${hasModeratorScopes}, headersTwitch: ${headersTwitch} '''; } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 70de0c63..60cd7dd7 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -9,6 +9,9 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; + /// Whether the active auth token includes Frosty's moderator OAuth scopes. + final bool Function() hasModeratorScopes; + /// The current user's info. @readonly UserTwitch? _details; @@ -23,7 +26,10 @@ abstract class UserStoreBase with Store { ReactionDisposer? _disposeReaction; - UserStoreBase({required this.twitchApi}); + UserStoreBase({ + required this.twitchApi, + required this.hasModeratorScopes, + }); @action Future init() async { @@ -36,11 +42,20 @@ abstract class UserStoreBase with Store { twitchApi .getUserBlockedList(id: _details!.id) .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); - twitchApi - .getModeratedChannels(id: _details!.id) - .then((channels) => _moderatedChannels = channels.asObservable()); + // Never hit moderated-channels Helix without moderator scopes — that 401 + // is what nags non-mod users after an app upgrade. + if (hasModeratorScopes()) { + twitchApi + .getModeratedChannels(id: _details!.id) + .then( + (channels) => _moderatedChannels = channels.asObservable(), + ); + } else { + _moderatedChannels = ObservableList(); + } } + _disposeReaction?.call(); _disposeReaction = autorun( (_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)), ); @@ -73,7 +88,7 @@ abstract class UserStoreBase with Store { /// status granted or revoked since then. @action Future refreshModeratedChannels() async { - if (_details?.id == null) return; + if (_details?.id == null || !hasModeratorScopes()) return; _moderatedChannels = (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); } @@ -85,18 +100,24 @@ abstract class UserStoreBase with Store { /// Whether the logged-in user can moderate [channelId] — either a moderator /// of that channel or its broadcaster (the broadcaster isn't in their own /// moderator list but can still use the moderation endpoints). + /// + /// Role only — does not require moderator OAuth scopes. Use + /// [canPerformModeratorActions] before calling Helix. bool canModerate(String channelId) => _details?.id == channelId || isModerator(channelId); + /// Role + moderator OAuth scopes — safe to call Helix mod endpoints. + bool canPerformModeratorActions(String channelId) => + _details?.id != null && + hasModeratorScopes() && + canModerate(channelId); + @action Future deleteMessage({ required String broadcasterId, required String messageId, }) async { - // Need the moderator ID and confirmed mod status for this channel. - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.deleteChatMessage( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -111,10 +132,7 @@ abstract class UserStoreBase with Store { int? duration, String? reason, }) async { - // Need the moderator ID and confirmed mod status for this channel. - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.banUser( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -130,9 +148,7 @@ abstract class UserStoreBase with Store { required String broadcasterId, required String userIdToUnban, }) async { - if (_details?.id == null || !canModerate(broadcasterId)) { - return false; - } + if (!canPerformModeratorActions(broadcasterId)) return false; return twitchApi.unbanUser( broadcasterId: broadcasterId, moderatorId: _details!.id, @@ -145,6 +161,7 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); - if (_disposeReaction != null) _disposeReaction!(); + _disposeReaction?.call(); + _disposeReaction = null; } } diff --git a/lib/utils/twitch_oauth_scopes.dart b/lib/utils/twitch_oauth_scopes.dart index 21a649b5..8e0677f9 100644 --- a/lib/utils/twitch_oauth_scopes.dart +++ b/lib/utils/twitch_oauth_scopes.dart @@ -1,4 +1,7 @@ -/// OAuth scopes Frosty requests for a logged-in Twitch user session. +/// Core OAuth scopes Frosty requests on a normal Twitch login. +/// +/// Matches the pre-moderator-tools set so existing tokens remain valid without +/// a re-consent nag for non-moderators. const twitchUserScopes = [ 'chat:read', 'chat:edit', @@ -6,15 +9,28 @@ const twitchUserScopes = [ 'user:read:blocked_users', 'user:manage:blocked_users', 'user:manage:chat_color', +]; + +/// Moderator-only scopes requested via an explicit opt-in upgrade. +const twitchModeratorScopes = [ 'user:read:moderated_channels', 'moderator:manage:chat_messages', 'moderator:manage:banned_users', ]; -/// Space-separated scope string for Twitch's authorize URL. +/// Core + moderator scopes for an opted-in moderator session. +const twitchAllUserScopes = [ + ...twitchUserScopes, + ...twitchModeratorScopes, +]; + +/// Space-separated scope string for Twitch's authorize URL (core only). String get twitchUserScopeQuery => twitchUserScopes.join(' '); -/// Human-readable labels for [twitchUserScopes]. +/// Space-separated scope string for Twitch's authorize URL (core + mod). +String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' '); + +/// Human-readable labels for Frosty-requested scopes. const twitchScopeLabels = { 'chat:read': 'Read chat messages', 'chat:edit': 'Send chat messages', @@ -27,6 +43,13 @@ const twitchScopeLabels = { 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', }; +/// Whether [granted] includes every moderator scope Frosty needs. +bool hasModeratorScopes(Iterable granted) => + missingTwitchScopes( + granted: granted, + required: twitchModeratorScopes, + ).isEmpty; + /// Scopes in [required] that are not present in [granted]. List missingTwitchScopes({ required Iterable granted, @@ -36,7 +59,15 @@ List missingTwitchScopes({ return required.where((scope) => !grantedSet.contains(scope)).toList(); } -/// Dialog body for a Twitch Helix/OAuth 401. +String _scopeBullets(Iterable scopes) => scopes + .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') + .join('\n'); + +/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request. +/// +/// Missing-permissions copy only lists **core** scopes. A token that already +/// has core scopes is treated as expired/invalid rather than "missing mod +/// permissions" so non-mods are never nagged about moderator consent. String unauthorizedDialogMessage({ required bool isLoggedIn, required Iterable grantedScopes, @@ -47,14 +78,17 @@ String unauthorizedDialogMessage({ final missing = missingTwitchScopes(granted: grantedScopes); if (missing.isEmpty) { - return 'Your session is missing permissions. Please log in again to continue.'; + return 'Your session has expired. Please log in again to continue.'; } - final bullets = missing - .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') - .join('\n'); - return 'Frosty needs additional Twitch permissions:\n\n' - '$bullets\n\n' + '${_scopeBullets(missing)}\n\n' 'Log in again to grant them.'; } + +/// Copy for the explicit moderator-tools opt-in dialog. +String enableModeratorToolsDialogMessage() { + return 'Enable moderator tools to manage chat in channels you moderate:\n\n' + '${_scopeBullets(twitchModeratorScopes)}\n\n' + 'You will be asked to authorize these permissions with Twitch.'; +} diff --git a/scripts/get_twitch_token.dart b/scripts/get_twitch_token.dart index 5605f378..22630424 100644 --- a/scripts/get_twitch_token.dart +++ b/scripts/get_twitch_token.dart @@ -31,6 +31,8 @@ void main() async { 'client_id': clientId, 'redirect_uri': redirectUri, 'response_type': 'token', + // Core scopes only — matches normal app login. Use the in-app + // "Enable moderator tools" flow when moderator scopes are needed. 'scope': twitchUserScopeQuery, }, ).toString(); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index e1a18f52..7727e23f 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -215,15 +215,16 @@ void main() { }); group('validateToken', () { - test('returns granted scopes for valid token', () async { + test('returns scopes and user id for valid token', () async { dioAdapter.onGet( 'https://id.twitch.tv/oauth2/validate', (server) => server.reply(200, twitchValidateTokenResponse), ); - final scopes = await api.validateToken(token: 'valid_token'); + final info = await api.validateToken(token: 'valid_token'); - expect(scopes, ['chat:read', 'chat:edit']); + expect(info?.scopes, ['chat:read', 'chat:edit']); + expect(info?.userId, '12345'); }); test('returns null for expired token (401)', () async { @@ -232,9 +233,9 @@ void main() { (server) => server.reply(401, {'message': 'invalid access token'}), ); - final scopes = await api.validateToken(token: 'expired_token'); + final info = await api.validateToken(token: 'expired_token'); - expect(scopes, isNull); + expect(info, isNull); }); test('rethrows on server error', () { diff --git a/test/apis/unauthorized_interceptor_paths_test.dart b/test/apis/unauthorized_interceptor_paths_test.dart new file mode 100644 index 00000000..a4855c54 --- /dev/null +++ b/test/apis/unauthorized_interceptor_paths_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/unauthorized_interceptor.dart'; + +void main() { + group('moderation path helpers', () { + test('matches helix moderation paths', () { + expect( + isTwitchModeratedChannelsPath('/helix/moderation/channels'), + isTrue, + ); + expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue); + expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue); + expect( + isTwitchModeratorActionPath('/helix/moderation/channels'), + isFalse, + ); + expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse); + }); + }); +} diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 56b1cf99..2155d611 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -684,5 +684,18 @@ void main() { final updated = original.fromIRCMessage(ircMsg); expect(updated.mod, isFalse); }); + + test('missing mod and subscriber tags are false', () { + const original = USERSTATE(); + final ircMsg = IRCMessage( + raw: '', + command: Command.userState, + tags: {'display-name': 'TestUser'}, + ); + + final updated = original.fromIRCMessage(ircMsg); + expect(updated.mod, isFalse); + expect(updated.subscriber, isFalse); + }); }); } diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart new file mode 100644 index 00000000..4affd3d7 --- /dev/null +++ b/test/screens/settings/stores/user_store_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +void main() { + late MockTwitchApi api; + + setUp(() { + api = MockTwitchApi(); + when(() => api.getUserInfo()).thenAnswer( + (_) async => const UserTwitch( + '12345', + 'testuser', + 'TestUser', + 'https://cdn/profile.png', + ), + ); + when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( + (_) async => [], + ); + when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer( + (_) async => ['999'], + ); + }); + + group('UserStore moderated channels gating', () { + test('init skips moderated-channels without moderator scopes', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + + await store.init(); + await Future.delayed(Duration.zero); + + verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); + expect(store.moderatedChannels, isEmpty); + }); + + test('init fetches moderated-channels when moderator scopes exist', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => true, + ); + + await store.init(); + await Future.delayed(Duration.zero); + + verify(() => api.getModeratedChannels(id: '12345')).called(1); + expect(store.moderatedChannels, ['999']); + }); + + test('refreshModeratedChannels no-ops without moderator scopes', () async { + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); + clearInteractions(api); + + await store.refreshModeratedChannels(); + verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); + }); + + test('deleteMessage requires moderator scopes', () async { + when( + () => api.deleteChatMessage( + broadcasterId: any(named: 'broadcasterId'), + moderatorId: any(named: 'moderatorId'), + messageId: any(named: 'messageId'), + ), + ).thenAnswer((_) async => true); + + final store = UserStore( + twitchApi: api, + hasModeratorScopes: () => false, + ); + await store.init(); + + final result = await store.deleteMessage( + broadcasterId: '12345', + messageId: 'msg', + ); + + expect(result, isFalse); + verifyNever( + () => api.deleteChatMessage( + broadcasterId: any(named: 'broadcasterId'), + moderatorId: any(named: 'moderatorId'), + messageId: any(named: 'messageId'), + ), + ); + }); + }); +} diff --git a/test/utils/twitch_oauth_scopes_test.dart b/test/utils/twitch_oauth_scopes_test.dart index 797c5d2e..94723f66 100644 --- a/test/utils/twitch_oauth_scopes_test.dart +++ b/test/utils/twitch_oauth_scopes_test.dart @@ -2,21 +2,49 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/utils/twitch_oauth_scopes.dart'; void main() { + group('scope sets', () { + test('core scopes exclude moderator scopes', () { + for (final scope in twitchModeratorScopes) { + expect(twitchUserScopes, isNot(contains(scope))); + } + expect(twitchAllUserScopes, containsAll(twitchUserScopes)); + expect(twitchAllUserScopes, containsAll(twitchModeratorScopes)); + }); + }); + + group('hasModeratorScopes', () { + test('requires all three moderator scopes', () { + expect(hasModeratorScopes(twitchAllUserScopes), isTrue); + expect( + hasModeratorScopes([ + ...twitchUserScopes, + 'user:read:moderated_channels', + 'moderator:manage:chat_messages', + ]), + isFalse, + ); + expect(hasModeratorScopes(twitchUserScopes), isFalse); + }); + }); + group('missingTwitchScopes', () { - test('returns scopes not present on the token', () { + test('defaults to core scopes only', () { final missing = missingTwitchScopes( granted: const ['chat:read', 'chat:edit'], ); - expect(missing, contains('user:read:moderated_channels')); - expect(missing, contains('moderator:manage:chat_messages')); - expect(missing, contains('moderator:manage:banned_users')); + expect(missing, contains('user:read:follows')); + expect(missing, isNot(contains('user:read:moderated_channels'))); expect(missing, isNot(contains('chat:read'))); }); test('returns empty when all required scopes are granted', () { + expect(missingTwitchScopes(granted: twitchUserScopes), isEmpty); expect( - missingTwitchScopes(granted: twitchUserScopes), + missingTwitchScopes( + granted: twitchAllUserScopes, + required: twitchModeratorScopes, + ), isEmpty, ); }); @@ -33,34 +61,34 @@ void main() { ); }); - test('lists human-readable missing permissions when logged in', () { + test('lists missing core permissions when logged in', () { final message = unauthorizedDialogMessage( isLoggedIn: true, - grantedScopes: const [ - 'chat:read', - 'chat:edit', - 'user:read:follows', - 'user:read:blocked_users', - 'user:manage:blocked_users', - 'user:manage:chat_color', - ], + grantedScopes: const ['chat:read', 'chat:edit'], ); expect(message, contains('additional Twitch permissions')); - expect(message, contains('View channels you moderate')); - expect(message, contains('Delete chat messages as a moderator')); - expect(message, contains('Timeout and ban users as a moderator')); - expect(message, isNot(contains('Send chat messages'))); + expect(message, contains('View followed channels')); + expect(message, isNot(contains('View channels you moderate'))); }); - test('falls back when logged in but no missing scopes are known', () { + test('prefers session expired when core scopes are complete', () { expect( unauthorizedDialogMessage( isLoggedIn: true, grantedScopes: twitchUserScopes, ), - contains('missing permissions'), + contains('expired'), ); }); }); + + group('enableModeratorToolsDialogMessage', () { + test('lists moderator permission labels', () { + final message = enableModeratorToolsDialogMessage(); + expect(message, contains('View channels you moderate')); + expect(message, contains('Delete chat messages as a moderator')); + expect(message, contains('Timeout and ban users as a moderator')); + }); + }); } From 0e26aefeb30cad78bd28ad32e9fea8baec6b0081 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Aug 2026 15:34:01 -0700 Subject: [PATCH 39/67] build(ios): refresh CocoaPods checksums and Firebase SPM pins Co-authored-by: Cursor --- ios/Podfile.lock | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6cba02bd..2cf6d774 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -19,9 +19,9 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" SPEC CHECKSUMS: - advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 + advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38e2de1f..6839f6d5 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", - "version" : "12.14.0" + "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", + "version" : "12.17.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", - "version" : "12.14.0" + "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", + "version" : "12.17.0" } }, { diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38e2de1f..6839f6d5 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", - "version" : "12.14.0" + "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", + "version" : "12.17.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", - "version" : "12.14.0" + "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", + "version" : "12.17.0" } }, { From 0937a1c5ad3af5cf53f826b4763da5537b8e8c49 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 15 Aug 2026 14:51:05 -0700 Subject: [PATCH 40/67] build(deps): bump Flutter to 3.47.0 and Android Gradle stack (#590) * build(deps): bump Flutter pin to 3.47.0 and Android Gradle stack Pin Flutter 3.47.0 (Dart 3.13) as the CI source of truth and move the Android toolchain to the 3.47 template versions: Gradle 9.3.1, AGP 9.1.0, Kotlin 2.4.0. App Gradle script matches the 3.47 template (drop kotlin-android / kotlinOptions, use compilerOptions). Keep android.builtInKotlin=false and android.newDsl=false so plugins that still apply KGP keep building. AGP 9 requires libraries to compile against their dependencies' compileSdk; force plugin modules to 36 via finalizeDsl. Ignore android/build/, which AGP 9 now writes a problems report into. analyze, test, and a release APK all passed. 16 KB zip alignment verified. iOS UIScene is not in this commit: AppDelegate is customized (CookieExtractorPlugin), so Flutter will not auto-migrate. * ci: drop unused Claude Code GitHub Actions Remove the PR review workflow and the @claude mention bot. Neither is used anymore; the review job just failed this PR on an expired OAuth token. * feat(ios): adopt UIScene lifecycle for Flutter 3.47 Adopt UIApplicationSceneManifest and FlutterImplicitEngineDelegate in AppDelegate to support the UIScene lifecycle requirement on iOS. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .github/workflows/claude-code-review.yml | 43 ---------------- .github/workflows/claude.yml | 49 ------------------- .gitignore | 2 + analysis_options.yaml | 3 ++ android/app/build.gradle.kts | 11 +++-- android/build.gradle.kts | 13 +++++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 +- ios/Flutter/AppFrameworkInfo.plist | 2 - ios/Podfile.lock | 2 +- ios/Runner/AppDelegate.swift | 14 ++++-- ios/Runner/Info.plist | 21 ++++++++ pubspec.lock | 18 +++---- pubspec.yaml | 2 +- 14 files changed, 68 insertions(+), 118 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 66e16681..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 50f44994..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.gitignore b/.gitignore index 1cbf0d64..dfee3eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +# AGP 9 writes a problems report here even when the Gradle build dir is redirected +/android/build/ # Folder containing random testing files random/ diff --git a/analysis_options.yaml b/analysis_options.yaml index de45531d..3b60af70 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -16,6 +16,9 @@ analyzer: - ios/** - android/** - web/** + - windows/** + - macos/** + - linux/** linter: # The lint rules applied to this project can be customized in the diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 596b2812..998334f3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id("com.google.gms.google-services") id("com.google.firebase.firebase-perf") id("com.google.firebase.crashlytics") - id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } @@ -27,10 +26,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - defaultConfig { applicationId = "com.tommychow.frosty" minSdk = flutter.minSdkVersion @@ -64,6 +59,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + flutter { source = "../.." } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657b..b477a6e8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,5 @@ +import com.android.build.api.variant.LibraryAndroidComponentsExtension + allprojects { repositories { google() @@ -19,6 +21,17 @@ subprojects { project.evaluationDependsOn(":app") } +// AGP 9 requires each library to compile against at least the compileSdk of +// its dependencies. Several Flutter plugins still pin compileSdk 33/34. +// finalizeDsl runs after the plugin's android {} block. Does not change minSdk. +subprojects { + pluginManager.withPlugin("com.android.library") { + extensions.configure("androidComponents") { + finalizeDsl { it.compileSdk = 36 } + } + } +} + tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index db3f4531..bcef617e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index f4978443..f68c56f0 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,8 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false id("com.google.gms.google-services") version "4.4.4" apply false id("com.google.firebase.firebase-perf") version "2.0.2" apply false id("com.google.firebase.crashlytics") version "3.0.6" apply false diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 0d140800..391a902b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 15.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2cf6d774..560b1f09 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -20,7 +20,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: advanced_in_app_review: daebe4c801525b756fe589de60fd327cb12f965c - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a66ef771..a51f2738 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,19 +2,23 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - guard let cookieExtractorRegistrar = self.registrar( + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + guard let cookieExtractorRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "CookieExtractorPlugin" ) else { assertionFailure("Failed to create CookieExtractorPlugin registrar") - return false + return } CookieExtractorPlugin.register(with: cookieExtractorRegistrar) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6052fdf9..1f85dcd5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -52,6 +52,27 @@ UIApplicationSupportsIndirectInputEvents + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + UIStatusBarHidden UIBackgroundModes diff --git a/pubspec.lock b/pubspec.lock index 128e2ed0..ea969bdb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -814,10 +814,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -830,10 +830,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1284,10 +1284,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -1404,10 +1404,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -1538,4 +1538,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: "3.44.9" + flutter: "3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index a2ec9777..b24b3fa0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ environment: sdk: ^3.9.0 # Exact Flutter pin, enforced by `flutter pub get` locally and read by CI via # `flutter-version-file: pubspec.yaml`. Single source of truth: bump here. - flutter: 3.44.9 + flutter: 3.47.0 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions From dc444810b15b385822d55b18a4cafce380f12e93 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 16 Aug 2026 11:06:26 -0700 Subject: [PATCH 41/67] docs(agents): split the release ritual into a repo-local playbook The full release procedure was loading in every Frosty session. Same split as the dotfiles resync playbook: always-on file stays gotchas, the ritual lives in docs/release.md. Co-authored-by: Cursor --- AGENTS.md | 61 +++++++++++++++++++++++-------------------------- docs/release.md | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 docs/release.md diff --git a/AGENTS.md b/AGENTS.md index 6ac5b315..9ff8a3c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,49 +1,46 @@ # AGENTS.md -Flutter mobile app for browsing Twitch on iOS and Android. Uses MobX for state management with code generation. +Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`. ## Commands ```bash -flutter pub get # Install dependencies -flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET # Run with Twitch credentials -flutter analyze # Static analysis -flutter test # Run tests -dart run build_runner build # Regenerate .g.dart files (MobX/JSON models) +flutter pub get +flutter run --dart-define-from-file=.env # local; .env is gitignored +flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET +flutter analyze +flutter test +flutter test test/path/to/file.dart +dart run build_runner build # after MobX / @JsonSerializable changes ``` -`--delete-conflicting-outputs` was removed in build_runner 2.15 and is now ignored — conflicting outputs are handled automatically. +`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it. -## Source Structure +## Layout -- `lib/apis/` — API services (all extend `BaseApiClient`) -- `lib/models/` — Data models (`@JsonSerializable` with `.g.dart` codegen) -- `lib/screens/{feature}/` — Feature screens with co-located `stores/` subdirectories -- `lib/stores/` — Global stores only (e.g., `global_assets_store.dart`) -- `lib/services/` — App-level services -- `lib/widgets/` — Shared widgets -- `lib/utils/` — Utility functions +- `lib/apis/` — API clients extend `BaseApiClient` +- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart` +- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/` +- `lib/stores/` — global stores only (`global_assets_store.dart`) +- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/` -## Architecture +Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering. -- Feature stores live in `lib/screens/{feature}/stores/` — don't create new top-level store files (only `lib/stores/` holds global stores) -- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes -- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example - -## Code Opinions - -- Extract related/grouped logic (state, reactions, actions) into dedicated stores when it improves readability — keep widgets focused on rendering -- Default to writing tests: new or changed logic (stores, API clients, parsing, utils) ships with tests; when touching existing tested code, update or extend its tests. Pure UI/rendering or config changes may skip. +New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`. ## Gotchas -- After changing MobX stores or `@JsonSerializable` models, regenerate with `dart run build_runner build`. Never edit `.g.dart` files directly. Commit `.g.dart` files to source control. -- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it. -- Several deps in `pubspec.yaml` are git-pinned forks carrying compatibility patches (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`). When upgrading dependencies, don't swap them back to pub.dev releases. +- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale. +- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it. +- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade. +- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev. +- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9. +- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template. +- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`. +- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher. +- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings. +- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing. -## Testing +## Releases -- `flutter test` runs all tests; `flutter test test/path/to/file.dart` for a single file -- HTTP mocking: `http_mock_adapter` (`DioAdapter`) — use full URLs in `onGet`/`onPost` -- General mocking: `mocktail` (no codegen required) -- Test layout mirrors `lib/` (`test/apis/`, `test/models/`, `test/screens/`); shared fixtures live in `test/fixtures/` (e.g., `irc_messages.dart`, `api_responses.dart`) +When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..479f9a14 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,55 @@ +# Release — version, notes, tag, then stores + +Cut a GitHub + Play + App Store release from `main`. Not a changelog dump. Not a docs rewrite. + +This playbook lives in the repo and loads only when you open this workspace and ask to release. It is not a global skill. + +Flow: **preflight → bump + notes → commit on `main` → tag → wait for workflows → publish the GitHub draft.** + +The tag is the mechanical trigger. This file is the judgment pass around it. + +## Preflight + +Main CI green on current `HEAD`. Then `gh workflow run "Release dry-run"` on that `main` — the monthly heartbeat is not enough after a toolchain bump. + +Click any pending Apple agreement first (`developer.apple.com/account`, then App Store Connect Business) or the iOS job 403s. There is no API for that. On Play, glance Console Home and **Policy and programs > App content** (`Needs attention`); a DDA banner or new declaration can block the AAB the same way. + +## Version + +Release commit on `main` is only `pubspec.yaml` (`x.y.z+build`, bump both) and `assets/release-notes.md`. Message: `release vX.Y.Z`. + +Features since last tag → minor. Fixes only → patch. Build number +1. + +Do not put `AGENTS.md` or other docs in that commit. + +## Notes + +Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest. + +- Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement. +- If you can't remember the user-facing symptom, it doesn't belong. +- `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent. +- Credit GitHub PR authors as `(by @user on GitHub)` on each bullet they landed. Not AI, not issue reporters. The GitHub release body strips ` on GitHub` as redundant. + +Example shape (from v5.1.0): `Added a new experimental native video player option with quality selection…` not `Bumped better_native_video_player`. + +## Tag + +```bash +git commit -m "release vX.Y.Z" +git push origin main +git tag vX.Y.Z # lightweight, on that commit +git push origin vX.Y.Z # v*.*.* triggers both workflows +``` + +Do not `gh release create`. **Draft GitHub release** attaches `frosty-vX.Y.Z.apk` and fills the body from this version's section in `assets/release-notes.md`. **Build and release Frosty** uploads the Play AAB and submits iOS for review (`automatic_release: true`). + +When the draft has the APK: + +```bash +gh release edit vX.Y.Z --draft=false +``` + +The README latest-release badge stays stale until that publish. Store listing copy already points at GitHub — don't add per-version Play changelog files. + +Do not push the tag until the release commit is on `origin/main`. From fcd7a304e8b99585b3393eb24646eb66aa679a0c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 16 Aug 2026 12:21:44 -0700 Subject: [PATCH 42/67] feat(video): make the native player the default 5.1.0 already serialized useNativePlayer: false, so existing installs need a one-shot migrate. WebView stays as a legacy opt-out. Co-authored-by: Cursor --- .../channel/video/native_video_store.dart | 10 +++--- .../settings/stores/settings_store.dart | 22 +++++++++++-- .../settings/stores/settings_store.g.dart | 16 +++++++++- lib/screens/settings/video_settings.dart | 4 +-- .../settings/stores/settings_store_test.dart | 31 +++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index e32a276c..faeb5c59 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -30,6 +30,8 @@ abstract class NativeVideoStoreBase with Store implements NativeVideoPlayerInterface { static int _nextId = 0; + static const _legacyWebViewInSettings = + 'the legacy WebView player in Settings'; @override final String userLogin; @@ -465,7 +467,7 @@ abstract class NativeVideoStoreBase // channel is expected to fail here and the overlay handles it. if (_streamInfo != null) { _showErrorOverlay( - 'Native player failed to load. Try the standard player in Settings.', + 'Native player failed to load. Try $_legacyWebViewInSettings.', ); } }); @@ -560,7 +562,7 @@ abstract class NativeVideoStoreBase _stallRecoveryTimer?.cancel(); final errorMessage = eventData?['message'] as String? ?? - 'Playback error. Try refreshing or switch to the standard player in Settings.'; + 'Playback error. Try refreshing or switch to $_legacyWebViewInSettings.'; final errorCode = eventData?['code'] as String?; final statusCode = eventData?['statusCode'] as int?; @@ -751,8 +753,8 @@ abstract class NativeVideoStoreBase _loading = false; _showErrorOverlay( bouncing - ? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to the standard player in Settings.' - : 'Stream stalled. Try refreshing or switch to the standard player in Settings.', + ? 'Twitch is having trouble with this channel right now. Try again in a moment, or switch to $_legacyWebViewInSettings.' + : 'Stream stalled. Try refreshing or switch to $_legacyWebViewInSettings.', ); }); } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 333f4e38..9330ad83 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -8,8 +8,11 @@ part 'settings_store.g.dart'; class SettingsStore extends _SettingsStoreBase with _$SettingsStore { SettingsStore(); - factory SettingsStore.fromJson(Map json) => - _$SettingsStoreFromJson(json); + factory SettingsStore.fromJson(Map json) { + final store = _$SettingsStoreFromJson(json); + store._migrateNativePlayerDefault(); + return store; + } Map toJson() => _$SettingsStoreToJson(this); } @@ -65,7 +68,7 @@ abstract class _SettingsStoreBase with Store { static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; - static const defaultUseNativePlayer = false; + static const defaultUseNativePlayer = true; static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; @@ -88,6 +91,19 @@ abstract class _SettingsStoreBase with Store { @observable var useNativePlayer = defaultUseNativePlayer; + /// 5.2.0 made native the default. 5.1.0 already wrote + /// `useNativePlayer: false` into saved settings, so a Dart default + /// change alone would leave existing installs on WebView. + @JsonKey(defaultValue: false) + var nativePlayerV52Migrated = false; + + @action + void _migrateNativePlayerDefault() { + if (nativePlayerV52Migrated) return; + useNativePlayer = true; + nativePlayerV52Migrated = true; + } + // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @observable diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 2e713294..4be11e2e 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -23,7 +23,8 @@ SettingsStore _$SettingsStoreFromJson( ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true - ..useNativePlayer = json['useNativePlayer'] as bool? ?? false + ..useNativePlayer = json['useNativePlayer'] as bool? ?? true + ..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? false @@ -106,6 +107,7 @@ Map _$SettingsStoreToJson( 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, 'useNativePlayer': instance.useNativePlayer, + 'nativePlayerV52Migrated': instance.nativePlayerV52Migrated, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'showLatency': instance.showLatency, @@ -1159,6 +1161,18 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { } } + @override + void _migrateNativePlayerDefault() { + final _$actionInfo = _$_SettingsStoreBaseActionController.startAction( + name: '_SettingsStoreBase._migrateNativePlayerDefault', + ); + try { + return super._migrateNativePlayerDefault(); + } finally { + _$_SettingsStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void resetVideoSettings() { final _$actionInfo = _$_SettingsStoreBaseActionController.startAction( diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 4e2aedd4..3eb39ac8 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -26,9 +26,9 @@ class VideoSettings extends StatelessWidget { ), if (settingsStore.showVideo) SettingsListSwitch( - title: 'Native player (experimental)', + title: 'Native player', subtitle: const Text( - 'More performant video player with auto picture-in-picture and quality selection.', + 'Picture-in-Picture, quality selection, and lower latency. Turn off to use the legacy WebView player.', ), value: settingsStore.useNativePlayer, onChanged: (newValue) => diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart index d2fad875..12e5c5b9 100644 --- a/test/screens/settings/stores/settings_store_test.dart +++ b/test/screens/settings/stores/settings_store_test.dart @@ -14,6 +14,7 @@ void main() { expect(store.showVideo, isTrue); expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); + expect(store.useNativePlayer, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse); @@ -102,6 +103,34 @@ void main() { }); }); + group('native player default migration', () { + test('5.1.0 WebView default is flipped on once', () { + final store = SettingsStore.fromJson({'useNativePlayer': false}); + + expect(store.useNativePlayer, isTrue); + expect(store.nativePlayerV52Migrated, isTrue); + }); + + test('opt-out is kept after the one-shot migration', () { + final store = SettingsStore.fromJson({ + 'useNativePlayer': false, + 'nativePlayerV52Migrated': true, + }); + + expect(store.useNativePlayer, isFalse); + }); + + test('roundtrip after opt-out stays on WebView', () { + final store = SettingsStore.fromJson({}); + store.useNativePlayer = false; + + final restored = SettingsStore.fromJson(store.toJson()); + + expect(restored.useNativePlayer, isFalse); + expect(restored.nativePlayerV52Migrated, isTrue); + }); + }); + group('SettingsStore reset actions', () { test('resetGeneralSettings restores general defaults', () { final store = SettingsStore.fromJson({}); @@ -127,6 +156,7 @@ void main() { store.showVideo = false; store.defaultToHighestQuality = true; store.useTextureRendering = false; + store.useNativePlayer = false; store.showOverlay = false; store.toggleableOverlay = true; store.showLatency = true; @@ -136,6 +166,7 @@ void main() { expect(store.showVideo, isTrue); expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); + expect(store.useNativePlayer, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse); From 1db8b95ba40c6bb13383d04453fc409985e28ec3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 16 Aug 2026 12:26:57 -0700 Subject: [PATCH 43/67] docs(agents): keep in-app notes to three consecutive versions Drop from the oldest end so the file stays short without holes in the version list. Co-authored-by: Cursor --- AGENTS.md | 2 +- docs/release.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9ff8a3c6..4495eb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,4 +43,4 @@ New or changed logic (stores, API clients, parsing, utils) ships with tests; ext ## Releases -When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. +When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip. diff --git a/docs/release.md b/docs/release.md index 479f9a14..f9927590 100644 --- a/docs/release.md +++ b/docs/release.md @@ -26,6 +26,8 @@ Do not put `AGENTS.md` or other docs in that commit. Write for someone opening the app, not the commit log. Match the latest shipped version's voice (`Added` / `Improved` / `Fixed`). Skip CI, Flutter upgrades, codegen, and anything a user wouldn't notice; the GitHub compare link is the rest. +Keep about three most recent versions, consecutive. Drop from the oldest end. Never skip a version in the middle. GitHub releases keep the full history. + - Features that didn't exist last release. Improvements only for behavior that already shipped — gating a new feature (opt-in permissions, an Enable flow) is not an improvement. - If you can't remember the user-facing symptom, it doesn't belong. - `[Android]` / `[iOS]` on platform-only lines. Cluster them: one tagged line at the end of its section, two or more adjacent. From 70de25b29b8389a81ee2e1f928047a14894a2336 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 16 Aug 2026 12:33:59 -0700 Subject: [PATCH 44/67] build(deps): pick up compatible lockfile bumps webview_flutter_android 4.14.0 is the only direct-ish change. Held secure_storage / plus packages / wakelock 1.7 for the win32 pin. Co-authored-by: Cursor --- pubspec.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index ea969bdb..b6205618 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,10 +278,10 @@ packages: dependency: transitive description: name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" url: "https://pub.dev" source: hosted - version: "0.7.14" + version: "0.7.13" device_info_plus: dependency: "direct main" description: @@ -622,10 +622,10 @@ packages: dependency: transitive description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" html: dependency: transitive description: @@ -670,10 +670,10 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.9.1" in_app_review: dependency: transitive description: @@ -1062,10 +1062,10 @@ packages: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "3b4ab682aff40175afca6ff090cc5aa7ce9dafe8dfbae1537a6c678e6678baee" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.0" rxdart: dependency: transitive description: @@ -1268,10 +1268,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" url: "https://pub.dev" source: hosted - version: "3.4.1+1" + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -1476,10 +1476,10 @@ packages: dependency: "direct main" description: name: webview_flutter_android - sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d url: "https://pub.dev" source: hosted - version: "4.13.0" + version: "4.14.0" webview_flutter_platform_interface: dependency: transitive description: @@ -1524,10 +1524,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: From 96b3eca604ad7314f288247c99d7bc890c3d1e9d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 16 Aug 2026 12:36:33 -0700 Subject: [PATCH 45/67] release v5.2.0 Co-authored-by: Cursor --- assets/release-notes.md | 123 +++++++--------------------------------- pubspec.yaml | 2 +- 2 files changed, 23 insertions(+), 102 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 6bc537b8..df21d898 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,25 @@ +## v5.2.0 + +August 16, 2026 + +### New Features + +- Added moderator actions to delete messages, timeout, ban, and unban users from the message long-press menu and user panel (by @francislavoie on GitHub). +- Added channel bubbles in Twitch shared chat, so you can focus one participant or hide their messages. + +### Improvements + +- Improved the default video player by switching to native. It handles Picture-in-Picture, quality selection, and latency better than the legacy WebView player. +- Improved native player stability across device rotation (by @francislavoie on GitHub). +- [Android] Improved native player latency for live streams (by @francislavoie on GitHub). + +### Bug Fixes + +- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream. +- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub). + +--- + ## v5.1.0 June 13, 2026 @@ -41,105 +63,4 @@ January 9, 2026 - Fixed landscape bottom padding in chat bottom bar. - [iOS] Fixed user agent breaking login. ---- - -## v5.0.4 - -January 8, 2026 - -### Improvements - -- Swipe down to PiP gesture now works in landscape mode. -- The rotate button now adjusts to your device's physical orientation instead of always rotating to the same direction. -- Replaced "Fill notch side" setting with "Fill all edges" and made edge padding auto-adjust based on device rotation. - -### Bug Fixes - -- Fixed Google login not working. -- [iOS] Fixed bottom safe area in landscape mode. - ---- - -## v5.0.3 - -January 6, 2026 - -### Improvements - -- Reconnection messages now update in place instead of spamming chat. -- Chat now shows sending status and relevant error notifications when messages fail to send. -- [Android] The "Enhanced rendering" video setting has been renamed to "Fast video rendering" and reset to enabled for all users. - -### Bug Fixes - -- Fixed send button not working in landscape mode. -- Fixed text field input being cleared before server confirmation. - ---- - -## v5.0.2 - -January 4, 2026 - -### Improvements - -- Inline emote support in the chat text field. -- Search page now uses type-to-search for faster navigation. -- Restored ability to send messages while chat delay is active. -- Restored chat delay input indicator. -- Redesigned the reply bar. - -### Bug Fixes - -- Fixed reply bar and autocomplete bar blocking the bottom of chat. -- Fixed clear recent emotes button sizing. -- Fixed black screen when logging in. - ---- - -## v5.0.1 - -January 2, 2026 - -### Improvements - -- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes. -- [Android] Re-enabled Flutter Impeller rendering engine for better performance. -- Restored the "Show latency" setting in video overlay options. - -### Bug Fixes - -- [Android] Potentially fixed crash on app startup related to secure storage. - ---- - -## v5.0.0 - -January 1, 2026 - -### New Features - -- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings). -- Resize the chat and video areas in landscape mode by dragging the divider. -- A new section in the Following tab shows which of your followed channels are currently offline. -- Change your chat username color from the chat details menu. -- A new randomize button for the accent color theme in settings. -- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures. -- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub). - -### Improvements - -- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit. -- Redesigned the video overlay and added stream details to the app bar in chat-only mode. -- Networking and data handling have been completely rewritten to be faster and more reliable. -- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays. - -### Bug Fixes - -- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub). -- Fixed missing badges and emotes in historical messages upon initial connection. -- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode. -- Fixed chat reconnection issues when a chat delay was active. -- Fixed duplicate message cases and various UI stability issues. - #### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases) diff --git a/pubspec.yaml b/pubspec.yaml index b24b3fa0..3466cc1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.1.0+69 +version: 5.2.0+70 environment: sdk: ^3.9.0 From a6d7ae6916015c54072737560dea8aaeed9e7edc Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 17 Aug 2026 18:52:05 -0700 Subject: [PATCH 46/67] chore(store): drop brand names from listing title and short copy Play rejected Twitch, 7TV, BTTV, and FFZ in the visible listing fields. Keep iOS aligned with the same card. Co-authored-by: Cursor --- android/fastlane/metadata/android/en-US/short_description.txt | 2 +- android/fastlane/metadata/android/en-US/title.txt | 2 +- ios/fastlane/metadata/en-US/name.txt | 2 +- ios/fastlane/metadata/en-US/promotional_text.txt | 2 +- ios/fastlane/metadata/en-US/subtitle.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/android/fastlane/metadata/android/en-US/short_description.txt index e633a6b1..cf35ecf1 100644 --- a/android/fastlane/metadata/android/en-US/short_description.txt +++ b/android/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ No newline at end of file +Watch live streams and see community emotes in chat. diff --git a/android/fastlane/metadata/android/en-US/title.txt b/android/fastlane/metadata/android/en-US/title.txt index 902e9c36..445c1415 100644 --- a/android/fastlane/metadata/android/en-US/title.txt +++ b/android/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Frosty for Twitch \ No newline at end of file +Frosty diff --git a/ios/fastlane/metadata/en-US/name.txt b/ios/fastlane/metadata/en-US/name.txt index 533e8f10..445c1415 100644 --- a/ios/fastlane/metadata/en-US/name.txt +++ b/ios/fastlane/metadata/en-US/name.txt @@ -1 +1 @@ -Frosty for Twitch +Frosty diff --git a/ios/fastlane/metadata/en-US/promotional_text.txt b/ios/fastlane/metadata/en-US/promotional_text.txt index e633a6b1..cf35ecf1 100644 --- a/ios/fastlane/metadata/en-US/promotional_text.txt +++ b/ios/fastlane/metadata/en-US/promotional_text.txt @@ -1 +1 @@ -Watch and chat on Twitch with your favorite emotes from 7TV, BTTV, and FFZ! \ No newline at end of file +Watch live streams and see community emotes in chat. diff --git a/ios/fastlane/metadata/en-US/subtitle.txt b/ios/fastlane/metadata/en-US/subtitle.txt index 0712b6dc..8c1195c5 100644 --- a/ios/fastlane/metadata/en-US/subtitle.txt +++ b/ios/fastlane/metadata/en-US/subtitle.txt @@ -1 +1 @@ -See 7TV, BTTV, and FFZ emotes! +Community emotes in chat From c50b3c3c69939502e60235ef0d469116b49e914f Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 17 Aug 2026 19:07:42 -0700 Subject: [PATCH 47/67] fix: correct typos and grammar in comments and login copy Co-authored-by: Cursor --- android/fastlane/Fastfile | 2 +- lib/apis/ffz_api.dart | 2 +- lib/models/irc.dart | 8 ++++---- lib/screens/channel/chat/stores/chat_assets_store.dart | 2 +- lib/screens/channel/chat/stores/chat_store.dart | 4 ++-- lib/screens/home/search/search.dart | 2 +- lib/screens/home/search/search_store.dart | 2 +- lib/screens/home/top/categories/category_card.dart | 2 +- lib/screens/onboarding/login_webview.dart | 2 +- lib/screens/onboarding/onboarding_login.dart | 2 +- lib/screens/settings/account/widgets/profile_card.dart | 2 +- lib/screens/settings/stores/auth_store.dart | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index d2392565..19e69c65 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -18,7 +18,7 @@ default_platform(:android) platform :android do desc "Deploy a new version to the Google Play" lane :deploy do |options| - # Upload to the play store ussing the provided credentials and AAB from flutter build. + # Upload to the Play Store using the provided credentials and AAB from flutter build. # With dry_run:true (release dry-run workflow), validate_only authenticates and # validates the AAB against Google Play without committing the release. upload_to_play_store( diff --git a/lib/apis/ffz_api.dart b/lib/apis/ffz_api.dart index d1918dee..32e70d19 100644 --- a/lib/apis/ffz_api.dart +++ b/lib/apis/ffz_api.dart @@ -42,7 +42,7 @@ class FFZApi extends BaseApiClient { ); } - /// Returns a map of badges user IDs to a list of their FFZ badges. + /// Returns a map of user IDs to a list of their FFZ badges. Future>> getBadges() async { final data = await get('/badges/ids'); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 54c9fb23..ba00cb52 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1025,7 +1025,7 @@ class IRCMessage { } // If the username exists, set it. - // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. + // tmi.twitch.tv means the message was sent by Twitch rather than a user, so it is irrelevant. final prefix = splitMessage[0]; final String? user; if (prefix == _twitchIrcServer) { @@ -1069,9 +1069,9 @@ class IRCMessage { indexBetweenIdAndPositions, ); - // Parse the range in order to extract the associated word. - // If there are more than one indices, use the first one. - // Else, use the one provided indices. + // Parse the range to extract the associated word. + // If there is more than one index, use the first. + // Otherwise, use the provided index. final String range; if (emoteIdAndPosition.contains(',')) { range = emoteIdAndPosition.substring( diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index 2efd626c..6a2187ca 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -130,7 +130,7 @@ abstract class ChatAssetsStoreBase with Store { @action Future init() async { - // Retrieve the instance that will allow us to retrieve local search history. + // Load SharedPreferences for recent emotes. final prefs = await SharedPreferences.getInstance(); _recentEmotes = diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 356d09db..e7baa1c1 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -30,7 +30,7 @@ abstract class ChatStoreBase with Store { /// The total maximum amount of messages in chat. static const _messageLimit = 5000; - /// The maximum ammount of messages to render when autoscroll is enabled. + /// The maximum amount of messages to render when autoscroll is enabled. static const _renderMessageLimit = 100; /// IRC commands that should bypass chat delay. @@ -271,7 +271,7 @@ abstract class ChatStoreBase with Store { @computed List get renderMessages { // If autoscroll is disabled, render ALL messages in chat. - // The second condition is to prevent an out of index error with sublist. + // The second condition is to prevent an out-of-range index error with sublist. final base = (!_autoScroll || _messages.length < _renderMessageLimit) ? _messages : _messages.sublist(_messages.length - _renderMessageLimit); diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index 6c58a1a4..c5cdb719 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -13,7 +13,7 @@ import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:provider/provider.dart'; -/// The search section that contians search history and search results for channels and categories. +/// The search section that contains search history and search results for channels and categories. class Search extends StatefulWidget { // The scroll controller for handling scroll to top functionality. final ScrollController scrollController; diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index 9c7a7f28..e2d0509e 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -46,7 +46,7 @@ abstract class SearchStoreBase with Store { @action Future init() async { - // Retrieve the instance that will allow us to retrieve local search history. + // Load SharedPreferences for local search history. final prefs = await SharedPreferences.getInstance(); // Retrieve the search history from local storage. If it doesn't exist, use an empty list. diff --git a/lib/screens/home/top/categories/category_card.dart b/lib/screens/home/top/categories/category_card.dart index 5845ae47..88daa502 100644 --- a/lib/screens/home/top/categories/category_card.dart +++ b/lib/screens/home/top/categories/category_card.dart @@ -18,7 +18,7 @@ class CategoryCard extends StatelessWidget { @override Widget build(BuildContext context) { - // Calculate the dimmensions of the box art based on the current dimmensions of the screen. + // Calculate the dimensions of the box art based on the current dimensions of the screen. final size = MediaQuery.of(context).size; final pixelRatio = MediaQuery.of(context).devicePixelRatio; final artWidth = (size.width * pixelRatio) ~/ 5; diff --git a/lib/screens/onboarding/login_webview.dart b/lib/screens/onboarding/login_webview.dart index b9096e8c..b631544d 100644 --- a/lib/screens/onboarding/login_webview.dart +++ b/lib/screens/onboarding/login_webview.dart @@ -55,7 +55,7 @@ class LoginWebView extends StatelessWidget { return FrostyDialog( title: 'Workaround for the Twitch cookie banner', message: - '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.', + '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, try logging in from the top-right profile icon. Once logged in, go back to the first onboarding step and try again.', actions: [ TextButton( onPressed: Navigator.of(context).pop, diff --git a/lib/screens/onboarding/onboarding_login.dart b/lib/screens/onboarding/onboarding_login.dart index 3cacfffc..f51de4ac 100644 --- a/lib/screens/onboarding/onboarding_login.dart +++ b/lib/screens/onboarding/onboarding_login.dart @@ -12,7 +12,7 @@ class OnboardingLogin extends StatelessWidget { return OnboardingScaffold( header: 'Log in', subtitle: - 'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.', + 'Frosty needs permission to chat, view followed streams, and more.', disclaimer: 'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.', buttonText: 'Connect with Twitch', diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index a91593f4..36d024b2 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -120,7 +120,7 @@ class ProfileCard extends StatelessWidget { leading: const Icon(Icons.no_accounts_rounded), title: const Text('Anonymous'), subtitle: const Text( - 'Log in to enable the ability to chat, view followed streams, and more.', + 'Log in to chat, view followed streams, and more.', ), trailing: const SizedBox( height: double.infinity, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index b0c36094..d0be4504 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -112,7 +112,7 @@ abstract class AuthBase with Store { } } - // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". + // Check if the URL has been redirected to "https://www.twitch.tv/?no-reload=true". // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (request.url == 'https://www.twitch.tv/?no-reload=true') { From bb150ce42ad3904ad3222c3c3516511c593a870c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 23 Aug 2026 11:08:03 -0700 Subject: [PATCH 48/67] chore(mcp): share dart server across grok, claude, and cursor Keep Next DevTools in both project MCP files so Cursor matches Claude's .mcp.json. --- .cursor/mcp.json | 12 ++++++++++++ .mcp.json | 4 ++++ 2 files changed, 16 insertions(+) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..0127976e --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, + "next-devtools": { + "command": "node", + "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] + } + } +} diff --git a/.mcp.json b/.mcp.json index 914a959a..0127976e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,5 +1,9 @@ { "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, "next-devtools": { "command": "node", "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] From 1aae7438bb241e8c51427fa84520b626344ecb1e Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 10:55:50 +0200 Subject: [PATCH 49/67] Add TwitchAdSolutions-style ad blocking for native and WebView players. Request playback with non-site player types and swap to backup Usher streams on stitched ad breaks; inject vendored vaft into the legacy WebView and expose a Settings toggle. Co-authored-by: Cursor --- DESIGN.md | 41 + assets/js/vaft.user.js | 1119 +++++++++++++++++ docs/DESIGN-adblock.md | 35 + lib/apis/twitch_ad_solutions.dart | 163 +++ lib/apis/twitch_gql_api.dart | 15 +- .../channel/video/native_video_store.dart | 127 +- .../channel/video/native_video_store.g.dart | 23 + lib/screens/channel/video/video_overlay.dart | 19 + .../channel/video/video_player_interface.dart | 3 + lib/screens/channel/video/video_store.dart | 34 +- .../settings/stores/settings_store.dart | 8 + .../settings/stores/settings_store.g.dart | 21 + lib/screens/settings/video_settings.dart | 15 + pubspec.yaml | 1 + test/apis/twitch_ad_solutions_test.dart | 84 ++ .../settings/stores/settings_store_test.dart | 1 + 16 files changed, 1697 insertions(+), 12 deletions(-) create mode 100644 DESIGN.md create mode 100644 assets/js/vaft.user.js create mode 100644 docs/DESIGN-adblock.md create mode 100644 lib/apis/twitch_ad_solutions.dart create mode 100644 test/apis/twitch_ad_solutions_test.dart diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..882b4114 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,41 @@ +# Frosty fork — Twitch ad blocking (DESIGN) + +Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking. + +Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**. + +## Problem + +Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker. + +## Approach (vaft-equivalent) + +Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only). + +| Layer | Behavior | +| --- | --- | +| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on | +| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL | +| Ad end | Restore primary popout Usher URL | +| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) | +| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” | + +## Key code + +- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback` +- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText` +- `lib/screens/channel/video/native_video_store.dart` — load + swap +- `lib/screens/channel/video/video_store.dart` — WebView inject +- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds` +- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script + +## Non-goals (v1) + +- Upstream PR +- Full Worker prototype cleaning +- Third-party proxies +- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) + +## Build + +Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/assets/js/vaft.user.js b/assets/js/vaft.user.js new file mode 100644 index 00000000..47c3e971 --- /dev/null +++ b/assets/js/vaft.user.js @@ -0,0 +1,1119 @@ +// ==UserScript== +// @name TwitchAdSolutions (vaft) +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 37.0.0 +// @description Multiple solutions for blocking Twitch ads (vaft) +// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @author https://github.com/cleanlock/VideoAdBlockForTwitch#credits +// @match *://*.twitch.tv/* +// @run-at document-start +// @inject-into page +// @grant none +// ==/UserScript== +(function() { + 'use strict'; + const ourTwitchAdSolutionsVersion = 24;// Used to prevent conflicts with outdated versions of the scripts + if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { + console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + return; + } + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + function declareOptions(scope) { + scope.AdSignifier = 'stitched'; + scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + scope.BackupPlayerTypes = [ + 'embed',//Source + 'popout',//Source + 'autoplay',//360p + //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) + ]; + scope.FallbackPlayerType = 'embed'; + scope.ForceAccessTokenPlayerType = 'popout'; + scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) + scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads + scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play + scope.PlayerReloadMinimalRequestsTime = 1500; + scope.PlayerReloadMinimalRequestsPlayerIndex = 2;//autoplay + scope.HasTriggeredPlayerReload = false; + scope.StreamInfos = []; + scope.StreamInfosByUrl = []; + scope.GQLDeviceID = null; + scope.ClientVersion = null; + scope.ClientSession = null; + scope.ClientIntegrityHeader = null; + scope.AuthorizationHeader = undefined; + scope.SimulatedAdsDepth = 0; + scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering + scope.PlayerBufferingDelay = 600;// How often should we check the player state (in milliseconds) + scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again) + scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check + scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer) + scope.PlayerBufferingMinRepeatDelay = 8000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) + scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play + scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) + scope.V2API = false; + scope.IsAdStrippingEnabled = true; + scope.AdSegmentCache = new Map(); + scope.AllSegmentsAreAdSegments = false; + } + let isActivelyStrippingAds = false; + let localStorageHookFailed = false; + const twitchWorkers = []; + const workerStringConflicts = [ + 'twitch', + 'isVariantA'// TwitchNoSub + ]; + const workerStringAllow = []; + const workerStringReinsert = [ + 'isVariantA',// TwitchNoSub (prior to (0.9)) + 'besuper/',// TwitchNoSub (0.9) + '${patch_url}'// TwitchNoSub (0.9.1) + ]; + function getCleanWorker(worker) { + let root = null; + let parent = null; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) { + if (parent !== null) { + Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); + } + } else { + if (root === null) { + root = proto; + } + parent = proto; + } + proto = Object.getPrototypeOf(proto); + } + return root; + } + function getWorkersForReinsert(worker) { + const result = []; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringReinsert.some((x) => workerString.includes(x))) { + result.push(proto); + } else { + } + proto = Object.getPrototypeOf(proto); + } + return result; + } + function reinsertWorkers(worker, reinsert) { + let parent = worker; + for (let i = 0; i < reinsert.length; i++) { + Object.setPrototypeOf(reinsert[i], parent); + parent = reinsert[i]; + } + return parent; + } + function isValidWorker(worker) { + const workerString = worker.toString(); + return !workerStringConflicts.some((x) => workerString.includes(x)) + || workerStringAllow.some((x) => workerString.includes(x)) + || workerStringReinsert.some((x) => workerString.includes(x)); + } + function hookWindowWorker() { + const reinsert = getWorkersForReinsert(window.Worker); + const newWorker = class Worker extends getCleanWorker(window.Worker) { + constructor(twitchBlobUrl, options) { + let isTwitchWorker = false; + try { + isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); + } catch {} + if (!isTwitchWorker) { + super(twitchBlobUrl, options); + return; + } + const newBlobStr = ` + const pendingFetchRequests = new Map(); + ${stripAdSegments.toString()} + ${getStreamUrlForResolution.toString()} + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${parseAttributes.toString()} + ${getWasmWorkerJs.toString()} + ${getServerTimeFromM3u8.toString()} + ${replaceServerTimeInM3u8.toString()} + const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); + declareOptions(self); + GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; + AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; + ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; + ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; + ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; + self.addEventListener('message', function(e) { + if (e.data.key == 'UpdateClientVersion') { + ClientVersion = e.data.value; + } else if (e.data.key == 'UpdateClientSession') { + ClientSession = e.data.value; + } else if (e.data.key == 'UpdateClientId') { + ClientID = e.data.value; + } else if (e.data.key == 'UpdateDeviceId') { + GQLDeviceID = e.data.value; + } else if (e.data.key == 'UpdateClientIntegrityHeader') { + ClientIntegrityHeader = e.data.value; + } else if (e.data.key == 'UpdateAuthorizationHeader') { + AuthorizationHeader = e.data.value; + } else if (e.data.key == 'FetchResponse') { + const responseData = e.data.value; + if (pendingFetchRequests.has(responseData.id)) { + const { resolve, reject } = pendingFetchRequests.get(responseData.id); + pendingFetchRequests.delete(responseData.id); + if (responseData.error) { + reject(new Error(responseData.error)); + } else { + // Create a Response object from the response data + const response = new Response(responseData.body, { + status: responseData.status, + statusText: responseData.statusText, + headers: responseData.headers + }); + resolve(response); + } + } + } else if (e.data.key == 'TriggeredPlayerReload') { + HasTriggeredPlayerReload = true; + } else if (e.data.key == 'SimulateAds') { + SimulatedAdsDepth = e.data.value; + console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); + } else if (e.data.key == 'AllSegmentsAreAdSegments') { + AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; + console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); + } + }); + hookWorkerFetch(); + eval(workerString); + `; + super(URL.createObjectURL(new Blob([newBlobStr])), options); + twitchWorkers.push(this); + this.addEventListener('message', (e) => { + if (e.data.key == 'UpdateAdBlockBanner') { + updateAdblockBanner(e.data); + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false); + } else if (e.data.key == 'ReloadPlayer') { + doTwitchPlayerTask(false, true); + } + }); + this.addEventListener('message', async event => { + if (event.data.key == 'FetchRequest') { + const fetchRequest = event.data.value; + const responseData = await handleWorkerFetchRequest(fetchRequest); + this.postMessage({ + key: 'FetchResponse', + value: responseData + }); + } + }); + } + }; + let workerInstance = reinsertWorkers(newWorker, reinsert); + Object.defineProperty(window, 'Worker', { + get: function() { + return workerInstance; + }, + set: function(value) { + if (isValidWorker(value)) { + workerInstance = value; + } else { + console.log('Attempt to set twitch worker denied'); + } + } + }); + } + function getWasmWorkerJs(twitchBlobUrl) { + const req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.overrideMimeType("text/javascript"); + req.send(); + return req.responseText; + } + function hookWorkerFetch() { + console.log('hookWorkerFetch (vaft)'); + const realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (AdSegmentCache.has(url)) { + return new Promise(function(resolve, reject) { + const send = function() { + return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) { + resolve(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + url = url.trimEnd(); + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status === 200) { + resolve(new Response(await processM3U8(url, await response.text(), realFetch))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { + V2API = url.includes('/api/v2/'); + const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (ForceAccessTokenPlayerType) { + // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads + const tempUrl = new URL(url); + tempUrl.searchParams.delete('parent_domains'); + url = tempUrl.toString(); + } + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status == 200) { + const encodingsM3u8 = await response.text(); + const serverTime = getServerTimeFromM3u8(encodingsM3u8); + let streamInfo = StreamInfos[channelName]; + if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0])).status !== 200) { + // The cached encodings are dead (the stream probably restarted) + streamInfo = null; + } + if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { + StreamInfos[channelName] = streamInfo = { + ChannelName: channelName, + IsShowingAd: false, + LastPlayerReload: 0, + EncodingsM3U8: encodingsM3u8, + ModifiedM3U8: null, + IsUsingModifiedM3U8: false, + UsherParams: (new URL(url)).search, + RequestedAds: new Set(), + Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } + ResolutionList: [], + BackupEncodingsM3U8Cache: [], + ActiveBackupPlayerType: null, + IsMidroll: false, + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0 + }; + const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(lines[i]); + const resolution = attributes['RESOLUTION']; + if (resolution) { + const resolutionInfo = { + Resolution: resolution, + FrameRate: attributes['FRAME-RATE'], + Codecs: attributes['CODECS'], + Url: lines[i + 1] + }; + streamInfo.Urls[lines[i + 1]] = resolutionInfo; + streamInfo.ResolutionList.push(resolutionInfo); + } + StreamInfosByUrl[lines[i + 1]] = streamInfo; + } + } + const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); + if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { + if (nonHevcResolutionList.length > 0) { + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF')) { + const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); + const codecsKey = 'CODECS'; + if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { + const oldResolution = resSettings['RESOLUTION']; + const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); + const newResolutionInfo = nonHevcResolutionList.sort((a, b) => { + // TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30) + const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); + const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); + return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); + })[0]; + console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); + lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); + lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique + } + } + } + } + if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { + streamInfo.ModifiedM3U8 = lines.join('\n'); + } + } + } + streamInfo.LastPlayerReload = Date.now(); + resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + } + return realFetch.apply(this, arguments); + }; + } + function getServerTimeFromM3u8(encodingsM3u8) { + if (V2API) { + const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/); + return matches.length > 1 ? matches[1] : null; + } + const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); + return matches.length > 1 ? matches[1] : null; + } + function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { + if (V2API) { + return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8; + } + return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; + } + function stripAdSegments(textStr, stripAllSegments, streamInfo) { + let hasStrippedAdSegments = false; + const lines = textStr.replaceAll('\r', '').split('\n'); + const newAdUrl = 'https://twitch.tv'; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + // Remove tracking urls which appear in the overlay UI + line = line + .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) + .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); + if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { + const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } + AdSegmentCache.set(segmentUrl, Date.now()); + hasStrippedAdSegments = true; + } + if (line.includes(AdSignifier)) { + hasStrippedAdSegments = true; + } + } + if (hasStrippedAdSegments) { + for (let i = 0; i < lines.length; i++) { + // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) + if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { + lines[i] = ''; + } + } + } else { + streamInfo.NumStrippedAdSegments = 0; + } + streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; + AdSegmentCache.forEach((value, key, map) => { + if (value < Date.now() - 120000) { + map.delete(key); + } + }); + return lines.join('\n'); + } + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { + const encodingsLines = encodingsM3u8.replaceAll('\r', '').split('\n'); + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); + let matchedResolutionUrl = null; + let matchedFrameRate = false; + let closestResolutionUrl = null; + let closestResolutionDifference = Infinity; + for (let i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(encodingsLines[i]); + const resolution = attributes['RESOLUTION']; + const frameRate = attributes['FRAME-RATE']; + if (resolution) { + if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { + matchedResolutionUrl = encodingsLines[i + 1]; + matchedFrameRate = frameRate == resolutionInfo.FrameRate; + if (matchedFrameRate) { + return matchedResolutionUrl; + } + } + const [width, height] = resolution.split('x').map(Number); + const difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; + } + } + } + } + return closestResolutionUrl; + } + async function processM3U8(url, textStr, realFetch) { + const streamInfo = StreamInfosByUrl[url]; + if (!streamInfo) { + return textStr; + } + if (HasTriggeredPlayerReload) { + HasTriggeredPlayerReload = false; + streamInfo.LastPlayerReload = Date.now(); + } + const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; + if (haveAdTags) { + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsShowingAd) { + streamInfo.IsShowingAd = true; + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: false + }); + } + if (!streamInfo.IsMidroll) { + const lines = textStr.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('#EXTINF') && lines.length > i + 1) { + if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) { + // Only request one .ts file per .m3u8 request to avoid making too many requests + //console.log('Fetch ad .ts file'); + streamInfo.RequestedAds.add(lines[i + 1]); + fetch(lines[i + 1]).then((response)=>{response.blob()}); + break; + } + } + } + } + const currentResolution = streamInfo.Urls[url]; + if (!currentResolution) { + console.log('Ads will leak due to missing resolution info for ' + url); + return textStr; + } + const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); + if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + streamInfo.IsUsingModifiedM3U8 = true; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } + let backupPlayerType = null; + let backupM3u8 = null; + let fallbackM3u8 = null; + let startIndex = 0; + let isDoingMinimalRequests = false; + if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { + // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays + startIndex = PlayerReloadMinimalRequestsPlayerIndex; + isDoingMinimalRequests = true; + } + for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { + const playerType = BackupPlayerTypes[playerTypeIndex]; + const realPlayerType = playerType.replace('-CACHED', ''); + const isFullyCachedPlayerType = playerType != realPlayerType; + for (let i = 0; i < 2; i++) { + // This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop) + let isFreshM3u8 = false; + let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; + if (!encodingsM3u8) { + isFreshM3u8 = true; + try { + const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType); + if (accessTokenResponse.status === 200) { + const accessToken = await accessTokenResponse.json(); + const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + const encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); + } + } + } catch (err) {} + } + if (encodingsM3u8) { + try { + const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); + const streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + const m3u8Text = await streamM3u8Response.text(); + if (m3u8Text) { + if (playerType == FallbackPlayerType) { + fallbackM3u8 = m3u8Text; + } + if ((!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= BackupPlayerTypes.length - 1)) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + if (isFullyCachedPlayerType) { + break; + } + if (isDoingMinimalRequests) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + } + } + } catch (err) {} + } + streamInfo.BackupEncodingsM3U8Cache[playerType] = null; + if (isFreshM3u8) { + break; + } + } + } + if (!backupM3u8 && fallbackM3u8) { + backupPlayerType = FallbackPlayerType; + backupM3u8 = fallbackM3u8; + } + if (backupM3u8) { + textStr = backupM3u8; + if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { + streamInfo.ActiveBackupPlayerType = backupPlayerType; + console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`); + } + } + // TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways) + const stripHevc = isHevc && streamInfo.ModifiedM3U8; + if (IsAdStrippingEnabled || stripHevc) { + textStr = stripAdSegments(textStr, stripHevc, streamInfo); + } + } else if (streamInfo.IsShowingAd) { + console.log('Finished blocking ads'); + streamInfo.IsShowingAd = false; + streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; + streamInfo.ActiveBackupPlayerType = null; + if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { + streamInfo.IsUsingModifiedM3U8 = false; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } else { + postMessage({ + key: 'PauseResumePlayer' + }); + } + } + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments + }); + return textStr; + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx + 1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; + })); + } + function getAccessToken(channelName, playerType) { + const body = { + operationName: 'PlaybackAccessToken', + variables: { + isLive: true, + login: channelName, + isVod: false, + vodID: "", + playerType: playerType, + platform: playerType == 'autoplay' ? 'android' : 'web' + }, + extensions: { + persistedQuery: { + version:1, + sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9" + } + } + }; + return gqlRequest(body, playerType); + } + function gqlRequest(body, playerType) { + if (!GQLDeviceID) { + GQLDeviceID = ''; + const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const dcharactersLength = dcharacters.length; + for (let i = 0; i < 32; i++) { + GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); + } + } + let headers = { + 'Client-ID': ClientID, + 'X-Device-Id': GQLDeviceID, + 'Authorization': AuthorizationHeader, + ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), + ...(ClientVersion && {'Client-Version': ClientVersion}), + ...(ClientSession && {'Client-Session-Id': ClientSession}) + }; + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(2, 15); + const fetchRequest = { + id: requestId, + url: 'https://gql.twitch.tv/gql', + options: { + method: 'POST', + body: JSON.stringify(body), + headers + } + }; + pendingFetchRequests.set(requestId, { + resolve, + reject + }); + postMessage({ + key: 'FetchRequest', + value: fetchRequest + }); + }); + } + let playerForMonitoringBuffering = null; + const playerBufferState = { + channelName: null, + hasStreamStarted: false, + position: 0, + bufferedPosition: 0, + bufferDuration: 0, + numSame: 0, + lastFixTime: 0, + isLive: true + }; + function monitorPlayerBuffering() { + if (playerForMonitoringBuffering) { + try { + const player = playerForMonitoringBuffering.player; + const state = playerForMonitoringBuffering.state; + if (!player.core) { + playerForMonitoringBuffering = null; + } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { + const m3u8Url = player.core?.state?.path; + if (m3u8Url) { + const fileName = new URL(m3u8Url).pathname.split('/').pop(); + if (fileName?.endsWith('.m3u8')) { + const channelName = fileName.slice(0, -5); + if (playerBufferState.channelName != channelName) { + playerBufferState.channelName = channelName; + playerBufferState.hasStreamStarted = false; + playerBufferState.numSame = 0; + //console.log('Channel changed to ' + channelName); + } + } + } + if (player.getState() === 'Playing') { + playerBufferState.hasStreamStarted = true; + } + const position = player.core?.state?.position; + const bufferedPosition = player.core?.state?.bufferedPosition; + const bufferDuration = player.getBufferDuration(); + if (position !== undefined && bufferedPosition !== undefined) { + //console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition + ' state: ' + player.core?.state?.state + ' started: ' + playerBufferState.hasStreamStarted); + // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play + if (playerBufferState.hasStreamStarted && + (!PlayerBufferingPrerollCheckEnabled || position > PlayerBufferingPrerollCheckOffset) && + (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && + playerBufferState.bufferedPosition == bufferedPosition && + playerBufferState.bufferDuration >= bufferDuration && + (position != 0 || bufferedPosition != 0 || bufferDuration != 0) + ) { + playerBufferState.numSame++; + if (playerBufferState.numSame == PlayerBufferingSameStateCount) { + console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); + const isPausePlay = !PlayerBufferingDoPlayerReload; + const isReload = PlayerBufferingDoPlayerReload; + doTwitchPlayerTask(isPausePlay, isReload); + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + } + } else { + playerBufferState.numSame = 0; + } + playerBufferState.position = position; + playerBufferState.bufferedPosition = bufferedPosition; + playerBufferState.bufferDuration = bufferDuration; + } else { + playerBufferState.numSame = 0; + } + } + } catch (err) { + console.error('error when monitoring player for buffering: ' + err); + playerForMonitoringBuffering = null; + } + } + if (!playerForMonitoringBuffering) { + const playerAndState = getPlayerAndState(); + if (playerAndState && playerAndState.player && playerAndState.state) { + playerForMonitoringBuffering = { + player: playerAndState.player, + state: playerAndState.state + }; + } + } + const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; + if (playerBufferState.isLive && !isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + playerBufferState.isLive = isLive; + setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); + } + function updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + isActivelyStrippingAds = data.isStrippingAdSegments; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && playerBufferState.isLive ? 'block' : 'none'; + } + } + } + function getPlayerAndState() { + function findReactNode(root, constraint) { + if (root.stateNode && constraint(root.stateNode)) { + return root.stateNode; + } + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; + } + node = node.sibling; + } + return null; + } + function findReactRootNode() { + let reactRootNode = null; + const rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; + } + if (reactRootNode == null && rootNode != null) { + const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); + if (containerName != null) { + reactRootNode = rootNode[containerName]; + } + } + return reactRootNode; + } + const reactRootNode = findReactRootNode(); + if (!reactRootNode) { + return null; + } + let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; + if (player?.playerInstance) { + player = player.playerInstance; + } + const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function doTwitchPlayerTask(isPausePlay, isReload) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; + if (!player) { + console.log('Could not find player'); + return; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.isPaused() || player.core?.paused) { + return; + } + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + if (isReload) { + const lsKeyQuality = 'video-quality'; + const lsKeyMuted = 'video-muted'; + const lsKeyVolume = 'volume'; + let currentQualityLS = null; + let currentMutedLS = null; + let currentVolumeLS = null; + try { + currentQualityLS = localStorage.getItem(lsKeyQuality); + currentMutedLS = localStorage.getItem(lsKeyMuted); + currentVolumeLS = localStorage.getItem(lsKeyVolume); + if (localStorageHookFailed && player?.core?.state) { + localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); + localStorage.setItem(lsKeyVolume, player.core.state.volume); + } + if (localStorageHookFailed && player?.core?.state?.quality?.group) { + localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); + } + } catch {} + console.log('Reloading Twitch player'); + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); + postTwitchWorkerMessage('TriggeredPlayerReload'); + player.play(); + if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) { + setTimeout(() => { + try { + if (currentQualityLS) { + localStorage.setItem(lsKeyQuality, currentQualityLS); + } + if (currentMutedLS) { + localStorage.setItem(lsKeyMuted, currentMutedLS); + } + if (currentVolumeLS) { + localStorage.setItem(lsKeyVolume, currentVolumeLS); + } + } catch {} + }, 3000); + } + return; + } + } + window.reloadTwitchPlayer = () => { + doTwitchPlayerTask(false, true); + }; + function postTwitchWorkerMessage(key, value) { + twitchWorkers.forEach((worker) => { + worker.postMessage({key: key, value: value}); + }); + } + async function handleWorkerFetchRequest(fetchRequest) { + try { + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; + } catch (error) { + return { + id: fetchRequest.id, + error: error.message + }; + } + } + function hookFetch() { + const realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + if (url.includes('gql')) { + let deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { + GQLDeviceID = deviceId; + postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); + } + if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { + postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); + } + if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { + postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); + } + if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) { + postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']); + } + if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { + postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); + } + // Get rid of mini player above chat - TODO: Reject this locally instead of having server reject it + if (init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { + init.body = ''; + } + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + let replacedPlayerType = ''; + const newBody = JSON.parse(init.body); + if (Array.isArray(newBody)) { + for (let i = 0; i < newBody.length; i++) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody[i].variables.playerType; + newBody[i].variables.playerType = ForceAccessTokenPlayerType; + } + } + } else { + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = ForceAccessTokenPlayerType; + } + } + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); + init.body = JSON.stringify(newBody); + } + } + } + } + return realFetch.apply(this, arguments); + }; + } + function onContentLoaded() { + // This stops Twitch from pausing the player when in another tab and an ad shows. + // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + }catch{} + let hidden = document.__lookupGetter__('hidden'); + let webkitHidden = document.__lookupGetter__('webkitHidden'); + try { + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + }catch{} + const block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + let wasVideoPlaying = true; + const visibilityChange = e => { + const isChrome = typeof chrome !== 'undefined'; + const videos = document.getElementsByTagName('video'); + if (videos.length > 0) { + if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { + wasVideoPlaying = !videos[0].paused && !videos[0].ended; + } else { + if (!playerBufferState.hasStreamStarted) { + //console.log('Tab focused. Stream should be active'); + playerBufferState.hasStreamStarted = true; + } + if (isChrome && wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { + videos[0].play(); + } + } + } + block(e); + }; + document.addEventListener('visibilitychange', visibilityChange, true); + document.addEventListener('webkitvisibilitychange', visibilityChange, true); + document.addEventListener('mozvisibilitychange', visibilityChange, true); + document.addEventListener('hasFocus', block, true); + try { + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + }catch{} + // Hooks for preserving volume / resolution + try { + const keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + const cachedValues = new Map(); + for (let i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + const realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + const realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } + } catch (err) { + console.log('localStorageHooks failed ' + err) + localStorageHookFailed = true; + } + } + declareOptions(window); + hookWindowWorker(); + hookFetch(); + if (PlayerBufferingFix) { + monitorPlayerBuffering(); + } + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } + window.simulateAds = (depth) => { + if (depth === undefined || depth < 0) { + console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); + return; + } + postTwitchWorkerMessage('SimulateAds', depth); + }; + window.allSegmentsAreAdSegments = () => { + postTwitchWorkerMessage('AllSegmentsAreAdSegments'); + }; +})(); diff --git a/docs/DESIGN-adblock.md b/docs/DESIGN-adblock.md new file mode 100644 index 00000000..b226d01f --- /dev/null +++ b/docs/DESIGN-adblock.md @@ -0,0 +1,35 @@ +# Design: Twitch ad blocking (vaft) in Frosty fork + +## Intent + +Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter. + +## Upstream baseline + +Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking. + +## Strategy + +### Native player (primary — this is the reliable path) + +1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`. +2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream. +3. When the ad break ends, restore the primary popout URL. +4. Overlay shows “Blocking ads” while `_blockingAds` is true. + +Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`. + +### WebView player (legacy, best-effort) + +Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on. + +## Settings + +- `blockTwitchAds` (default `true`) +- Turning adblock on sets `useNativePlayer = true` + +## Non-goals + +- Upstream PR +- Full browser Worker prototype rewriting on native +- Third-party CDN proxies unless player-type swap proves insufficient diff --git a/lib/apis/twitch_ad_solutions.dart b/lib/apis/twitch_ad_solutions.dart new file mode 100644 index 00000000..1647f4f0 --- /dev/null +++ b/lib/apis/twitch_ad_solutions.dart @@ -0,0 +1,163 @@ +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) +/// strategy for Frosty's native HLS player. +/// +/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead: +/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType] +/// (vaft's `ForceAccessTokenPlayerType`). +/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist +/// without the ad signifier is found, then hand that Usher URL to the player. +class TwitchAdSolutions { + TwitchAdSolutions._(); + + /// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`. + static const adSignifier = 'stitched'; + + /// vaft `ForceAccessTokenPlayerType` — used for the primary stream when + /// ad blocking is enabled. + static const forceAccessTokenPlayerType = 'popout'; + + /// vaft `BackupPlayerTypes` order when the primary playlist still has ads. + static const backupPlayerTypes = [ + 'embed', + 'popout', + 'autoplay', + ]; + + /// vaft `FallbackPlayerType` when every backup still carries ads. + static const fallbackPlayerType = 'embed'; + + /// GQL `platform` for a given player type (vaft uses `android` for autoplay). + static String platformForPlayerType(String playerType) => + playerType == 'autoplay' ? 'android' : 'web'; + + static bool playlistContainsAds(String playlistText) => + playlistText.contains(adSignifier); + + /// True when [text] looks like a midroll (vaft midroll banner path). + static bool playlistIsMidroll(String playlistText) => + playlistText.contains('"MIDROLL"') || + playlistText.contains('"midroll"') || + playlistText.contains("MIDROLL"); +} + +/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy. +class TwitchAdFreePlayback { + TwitchAdFreePlayback(this._gql); + + final TwitchGqlApi _gql; + + /// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType]. + Future<({String url, PlaybackAccessToken token, String playerType})> + resolvePrimary({ + required String login, + String? authToken, + }) { + return _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: TwitchAdSolutions.forceAccessTokenPlayerType, + requireAdFree: false, + ); + } + + /// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads. + /// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain. + Future<({String url, PlaybackAccessToken token, String playerType})> + resolveBackup({ + required String login, + String? authToken, + }) async { + for (final playerType in TwitchAdSolutions.backupPlayerTypes) { + try { + final resolved = await _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: playerType, + requireAdFree: true, + ); + debugPrint( + 'TwitchAdFreePlayback: backup OK playerType=$playerType', + ); + return resolved; + } catch (e) { + debugPrint( + 'TwitchAdFreePlayback: backup playerType=$playerType failed: $e', + ); + } + } + + debugPrint( + 'TwitchAdFreePlayback: all backups had ads; using fallback ' + '${TwitchAdSolutions.fallbackPlayerType}', + ); + return _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: TwitchAdSolutions.fallbackPlayerType, + requireAdFree: false, + ); + } + + Future<({String url, PlaybackAccessToken token, String playerType})> + _resolveForPlayerType({ + required String login, + String? authToken, + required String playerType, + required bool requireAdFree, + }) async { + final token = await _gql.getPlaybackAccessToken( + login: login, + authToken: authToken, + playerType: playerType, + platform: TwitchAdSolutions.platformForPlayerType(playerType), + ); + final url = _gql.buildHlsUrl(login: login, token: token); + + if (requireAdFree) { + final hasAds = await _multivariantHasAds(url); + if (hasAds) { + throw StateError('Playlist for playerType=$playerType still has ads'); + } + } + + return (url: url, token: token, playerType: playerType); + } + + /// Fetches the multivariant playlist and samples the first media variant for + /// the ad signifier (same idea as vaft probing backup encodings). + Future _multivariantHasAds(String hlsUrl) async { + final master = await _gql.fetchPlaylistText(hlsUrl); + final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl); + if (mediaUrl == null) { + // Can't verify — treat as ad-free so we don't reject a usable stream. + return false; + } + final media = await _gql.fetchPlaylistText(mediaUrl); + return TwitchAdSolutions.playlistContainsAds(media); + } + + /// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl]. + @visibleForTesting + static String? firstMediaPlaylistUrl( + String masterPlaylist, { + required String baseUrl, + }) { + final lines = masterPlaylist.replaceAll('\r', '').split('\n'); + for (final raw in lines) { + final line = raw.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + if (!line.contains('.m3u8')) continue; + return Uri.parse(baseUrl).resolve(line).toString(); + } + return null; + } +} + +/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed +/// for tests / future local playlist rewriting; not required for URL-swap mode. +const twitchAdSolutionsBlankSegmentDataUri = + 'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA'; diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 9d6a5605..f72e7250 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -34,12 +34,18 @@ class TwitchGqlApi extends BaseApiClient { /// When [authToken] is provided, the request is authenticated which may /// grant ad-free playback for subscribers and Turbo users. /// + /// [playerType] / [platform] mirror the web player's GQL variables. Ad + /// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as + /// `popout` / `embed` / `autoplay`. + /// /// Uses the web client's persisted GQL query (smaller payload, blends in /// with regular web traffic); falls back to the inline query template if /// Twitch ever rotates the persisted-query hash. Future getPlaybackAccessToken({ required String login, String? authToken, + String playerType = 'site', + String platform = 'web', }) async { final headers = { 'Client-ID': twitchGqlClientId, @@ -52,8 +58,8 @@ class TwitchGqlApi extends BaseApiClient { 'login': login, 'isVod': false, 'vodID': '', - 'playerType': 'site', - 'platform': 'web', + 'playerType': playerType, + 'platform': platform, }; Future query(Map body) => @@ -92,6 +98,11 @@ class TwitchGqlApi extends BaseApiClient { return PlaybackAccessToken.fromGqlResponse(response); } + /// Fetches raw playlist text (multivariant or media) with playback headers. + Future fetchPlaylistText(String url) async { + return get(url, headers: playbackHttpHeaders); + } + /// Builds the HLS stream URL for the given channel [login] and [token]. String buildHlsUrl({ required String login, diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index faeb5c59..28911fd4 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -6,6 +6,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_ad_solutions.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; @@ -113,9 +114,23 @@ abstract class NativeVideoStoreBase /// suspended rather than burning refresh attempts on ad-induced noise. var _isAdBreakActive = false; + /// True after we swapped to a TwitchAdSolutions backup player-type URL. + var _usingAdBackup = false; + + /// Serializes ad-break URL swaps so overlapping adBreakStream events don't + /// race loadUrl calls. + var _adSwapInFlight = false; + + late final TwitchAdFreePlayback _adFreePlayback = + TwitchAdFreePlayback(twitchGqlApi); + @readonly var _loading = true; + /// Whether ad blocking is actively avoiding/replacing an ad pod. + @readonly + var _blockingAds = false; + @readonly var _paused = true; @@ -276,6 +291,12 @@ abstract class NativeVideoStoreBase debugPrint( 'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}', ); + if (!settingsStore.blockTwitchAds) { + runInAction(() => _blockingAds = false); + return; + } + // Fire-and-forget: swap stream URL per TwitchAdSolutions backup path. + unawaited(_onAdBreakChanged(isActive)); }); _pipSub = _controller!.isPipEnabledStream.listen((isPip) { @@ -378,12 +399,27 @@ abstract class NativeVideoStoreBase Future _loadStream(int generation) async { // Use web cookie token (works with web Client-ID) for ad-free playback. final authToken = authStore.gqlToken; + late final String hlsUrl; late final PlaybackAccessToken token; try { - token = await twitchGqlApi.getPlaybackAccessToken( - login: userLogin, - authToken: authToken, - ); + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authToken, + ); + hlsUrl = resolved.url; + token = resolved.token; + debugPrint( + 'NativeVideoStore: adblock primary ' + 'playerType=${resolved.playerType}', + ); + } else { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } } catch (e) { if (authToken != null) { debugPrint('NativeVideoStore: auth token failed, retrying without: $e'); @@ -392,7 +428,16 @@ abstract class NativeVideoStoreBase // reflects the true state and future loads skip this request. authStore.invalidateGqlToken(); } - token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + ); + hlsUrl = resolved.url; + token = resolved.token; + } else { + token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } } else { rethrow; } @@ -401,10 +446,11 @@ abstract class NativeVideoStoreBase if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { return; } - runInAction( - () => _restrictedStreamQualities = token.restrictedQualities, - ); - final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + runInAction(() { + _restrictedStreamQualities = token.restrictedQualities; + _usingAdBackup = false; + _blockingAds = false; + }); await _controller!.loadUrl( url: hlsUrl, @@ -417,6 +463,66 @@ abstract class NativeVideoStoreBase _startLatencyPolling(); } + /// TwitchAdSolutions backup swap when the native player reports a stitched + /// ad break; restores the primary (popout) URL when the break ends. + Future _onAdBreakChanged(bool isActive) async { + if (_disposed || !settingsStore.blockTwitchAds || _controller == null) { + return; + } + if (_adSwapInFlight) return; + _adSwapInFlight = true; + final generation = _initGeneration; + try { + if (isActive) { + runInAction(() => _blockingAds = true); + final resolved = await _adFreePlayback.resolveBackup( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + debugPrint( + 'NativeVideoStore: blocking ads via ' + 'playerType=${resolved.playerType}', + ); + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = true; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else if (_usingAdBackup) { + debugPrint('NativeVideoStore: ad break ended, restoring primary'); + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = false; + _blockingAds = false; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else { + runInAction(() => _blockingAds = false); + } + } catch (e) { + debugPrint('NativeVideoStore: adblock swap failed: $e'); + runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup); + } finally { + _adSwapInFlight = false; + } + } + /// Shared error handler for both `_initPlayer` and `_reloadCurrentController`. /// Checks stream liveness, auto-retries if CDN is still serving stale /// manifests, or surfaces the error with the overlay visible. @@ -1278,6 +1384,9 @@ abstract class NativeVideoStoreBase _paused = true; _isStalled = false; _isAdBreakActive = false; + _usingAdBackup = false; + _blockingAds = false; + _adSwapInFlight = false; _restrictedStreamQualities = []; _isQualitySwitching = false; _isAudioOnlyMode = false; diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index 8f1167ce..71fadd4f 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -57,6 +57,29 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { }); } + late final _$_blockingAdsAtom = Atom( + name: 'NativeVideoStoreBase._blockingAds', + context: context, + ); + + bool get blockingAds { + _$_blockingAdsAtom.reportRead(); + return super._blockingAds; + } + + @override + bool get isBlockingAds => blockingAds; + + @override + bool get _blockingAds => blockingAds; + + @override + set _blockingAds(bool value) { + _$_blockingAdsAtom.reportWrite(value, super._blockingAds, () { + super._blockingAds = value; + }); + } + late final _$_pausedAtom = Atom( name: 'NativeVideoStoreBase._paused', context: context, diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index eceb6c08..f3589112 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -322,6 +322,25 @@ class VideoOverlay extends StatelessWidget { height: 80, // Covers bottom area around controls child: Container(decoration: bottomGradient), ), + if (videoStore.isBlockingAds) + Positioned( + top: 8, + left: 8, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: Text( + 'Blocking ads', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ), + ), // Content Stack( children: [ diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart index 9838e7ad..e8dd75d0 100644 --- a/lib/screens/channel/video/video_player_interface.dart +++ b/lib/screens/channel/video/video_player_interface.dart @@ -29,6 +29,9 @@ abstract class VideoPlayerInterface { String get streamQuality; String? get latency; + /// True while TwitchAdSolutions is actively avoiding an ad break. + bool get isBlockingAds; + void handleVideoTap(); void handlePausePlay(); void handleToggleOverlay(); diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index cdf22744..971a25bf 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -183,9 +183,20 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // Every real navigation invalidates the injected JS context, so // every one must re-arm init. Unconditional is safe: the // onPageFinished handler below still filters on videoUrl. - onPageStarted: (url) => _needsInit = true, + onPageStarted: (url) { + _needsInit = true; + // Best-effort: inject vaft as early as the WebView allows. + // True document-start injection isn't exposed by webview_flutter + // on Android; native player remains the reliable adblock path. + if (settingsStore.blockTwitchAds) { + unawaited(_injectVaftUserScript()); + } + }, onPageFinished: (url) async { if (url != videoUrl) return; + if (settingsStore.blockTwitchAds) { + await _injectVaftUserScript(); + } if (!_needsInit) return; _needsInit = false; await initVideo(); @@ -263,6 +274,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { @readonly var _isInPipMode = false; + /// WebView path relies on the injected vaft userscript's own overlay; the + /// Dart overlay banner is driven by the native player only. + @override + bool get isBlockingAds => false; + /// The video URL to use for the webview. String get videoUrl => 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; @@ -636,6 +652,22 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } } + /// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page. + Future _injectVaftUserScript() async { + try { + final raw = await rootBundle.loadString('assets/js/vaft.user.js'); + // Strip UserScript metadata; keep the IIFE body. + final script = raw.replaceFirst( + RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true), + '', + ); + await videoWebViewController.runJavaScript(script); + debugPrint('VideoStore: injected TwitchAdSolutions vaft'); + } catch (e) { + debugPrint('VideoStore: vaft inject failed: $e'); + } + } + /// Called whenever the video/overlay is tapped. @override @action diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 9330ad83..30599f99 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -69,6 +69,7 @@ abstract class _SettingsStoreBase with Store { static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; static const defaultUseNativePlayer = true; + static const defaultBlockTwitchAds = true; static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; @@ -91,6 +92,12 @@ abstract class _SettingsStoreBase with Store { @observable var useNativePlayer = defaultUseNativePlayer; + /// TwitchAdSolutions (vaft)-style ad blocking for the native player, and + /// userscript injection for the legacy WebView player. + @JsonKey(defaultValue: defaultBlockTwitchAds) + @observable + var blockTwitchAds = defaultBlockTwitchAds; + /// 5.2.0 made native the default. 5.1.0 already wrote /// `useNativePlayer: false` into saved settings, so a Dart default /// change alone would leave existing installs on WebView. @@ -123,6 +130,7 @@ abstract class _SettingsStoreBase with Store { defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; useNativePlayer = defaultUseNativePlayer; + blockTwitchAds = defaultBlockTwitchAds; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 4be11e2e..a583ac94 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson( ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true ..useNativePlayer = json['useNativePlayer'] as bool? ?? true + ..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true ..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false @@ -107,6 +108,7 @@ Map _$SettingsStoreToJson( 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, 'useNativePlayer': instance.useNativePlayer, + 'blockTwitchAds': instance.blockTwitchAds, 'nativePlayerV52Migrated': instance.nativePlayerV52Migrated, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, @@ -352,6 +354,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$blockTwitchAdsAtom = Atom( + name: '_SettingsStoreBase.blockTwitchAds', + context: context, + ); + + @override + bool get blockTwitchAds { + _$blockTwitchAdsAtom.reportRead(); + return super.blockTwitchAds; + } + + @override + set blockTwitchAds(bool value) { + _$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () { + super.blockTwitchAds = value; + }); + } + late final _$showOverlayAtom = Atom( name: '_SettingsStoreBase.showOverlay', context: context, @@ -1245,6 +1265,7 @@ showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, useNativePlayer: ${useNativePlayer}, +blockTwitchAds: ${blockTwitchAds}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 3eb39ac8..6e3ba50e 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -34,6 +34,21 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useNativePlayer = newValue, ), + if (settingsStore.showVideo) + SettingsListSwitch( + title: 'Block Twitch ads', + subtitle: Text( + settingsStore.useNativePlayer + ? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.' + : 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.', + ), + value: settingsStore.blockTwitchAds, + onChanged: (newValue) { + settingsStore.blockTwitchAds = newValue; + // Ad blocking is most effective on the native path. + if (newValue) settingsStore.useNativePlayer = true; + }, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', diff --git a/pubspec.yaml b/pubspec.yaml index 3466cc1c..9a54d21d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -151,6 +151,7 @@ flutter: - assets/icons/logo.svg - assets/fonts/ - assets/release-notes.md + - assets/js/vaft.user.js # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/apis/twitch_ad_solutions_test.dart b/test/apis/twitch_ad_solutions_test.dart new file mode 100644 index 00000000..44103bc6 --- /dev/null +++ b/test/apis/twitch_ad_solutions_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_ad_solutions.dart'; + +void main() { + group('TwitchAdSolutions', () { + test('playlistContainsAds detects stitched signifier', () { + expect( + TwitchAdSolutions.playlistContainsAds( + '#EXTM3U\n#EXT-X-DATERANGE:ID="stitched-ad-1"\n', + ), + isTrue, + ); + expect( + TwitchAdSolutions.playlistContainsAds('#EXTM3U\n#EXTINF:2.0,\nlive.ts\n'), + isFalse, + ); + }); + + test('playlistIsMidroll detects midroll markers', () { + expect( + TwitchAdSolutions.playlistIsMidroll('X-TV-TWITCH-AD-POD-TYPE="MIDROLL"'), + isTrue, + ); + expect(TwitchAdSolutions.playlistIsMidroll('live only'), isFalse); + }); + + test('platformForPlayerType matches vaft', () { + expect(TwitchAdSolutions.platformForPlayerType('autoplay'), 'android'); + expect(TwitchAdSolutions.platformForPlayerType('popout'), 'web'); + expect(TwitchAdSolutions.platformForPlayerType('embed'), 'web'); + }); + + test('force / backup player types match vaft defaults', () { + expect(TwitchAdSolutions.forceAccessTokenPlayerType, 'popout'); + expect(TwitchAdSolutions.backupPlayerTypes, [ + 'embed', + 'popout', + 'autoplay', + ]); + expect(TwitchAdSolutions.fallbackPlayerType, 'embed'); + }); + }); + + group('TwitchAdFreePlayback.firstMediaPlaylistUrl', () { + test('resolves relative media playlist against base', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360 +360p.m3u8 +'''; + final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( + master, + baseUrl: 'https://usher.ttvnw.net/api/v2/channel/hls/foo.m3u8?x=1', + ); + expect( + url, + 'https://usher.ttvnw.net/api/v2/channel/hls/360p.m3u8', + ); + }); + + test('keeps absolute media playlist URLs', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1000 +https://video-weaver.example/v1/playlist/abc.m3u8 +'''; + final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( + master, + baseUrl: 'https://usher.ttvnw.net/foo.m3u8', + ); + expect(url, 'https://video-weaver.example/v1/playlist/abc.m3u8'); + }); + + test('returns null when no media URI present', () { + expect( + TwitchAdFreePlayback.firstMediaPlaylistUrl( + '#EXTM3U\n', + baseUrl: 'https://example.com/x.m3u8', + ), + isNull, + ); + }); + }); +} diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart index 12e5c5b9..f6e6612c 100644 --- a/test/screens/settings/stores/settings_store_test.dart +++ b/test/screens/settings/stores/settings_store_test.dart @@ -15,6 +15,7 @@ void main() { expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); expect(store.useNativePlayer, isTrue); + expect(store.blockTwitchAds, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse); From cdbccc23a8eafb299a242702fa33fa44d472acd9 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 10:59:23 +0200 Subject: [PATCH 50/67] Add Forgejo Actions release workflow for Obtainium APK updates. Build a signed Flutter APK on desktop-release and publish versioned assets so private-repo Obtainium can track frosty-arm64-v8a-v*.apk. Co-authored-by: Cursor --- .forgejo/workflows/release.yml | 304 +++++++++++++++++++++++++++++++++ docs/OBTAINIUM.md | 45 +++++ 2 files changed, 349 insertions(+) create mode 100644 .forgejo/workflows/release.yml create mode 100644 docs/OBTAINIUM.md diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 00000000..8e8ac8fd --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,304 @@ +# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium. +# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`. +# +# Required repository secrets: +# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD +# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define) +# +# Obtainium: +# Source: Forgejo +# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases +# APK filter: frosty-arm64-v8a-v.*\.apk +# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+) +# Match group: 1 +# Private repo → Forgejo API token in Obtainium. + +name: Release APK + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: frosty-release + cancel-in-progress: true + +jobs: + build-and-release: + name: Build & publish release + runs-on: desktop-release + + steps: + - name: Clone repository + shell: bash + env: + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + rm -rf .git + git init . + origin_url="${GITHUB_SERVER_URL#https://}" + origin_url="${origin_url#http://}" + git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" + git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}" + git checkout --force FETCH_HEAD + test -f pubspec.yaml + test -f android/app/build.gradle.kts + echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}" + + - name: Set up JDK + shell: bash + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else "$@"; fi + } + if ! command -v java >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y openjdk-21-jdk + elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21 + fi + fi + java -version + if command -v javac >/dev/null 2>&1; then + java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" + echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV" + fi + + - name: Set up Android SDK + shell: bash + run: | + set -euo pipefail + sdk_candidates=( + "${ANDROID_HOME:-}" + "${ANDROID_SDK_ROOT:-}" + "/home/yuna/Android/Sdk" + "${HOME}/Android/Sdk" + "/opt/android-sdk-linux" + "/root/Android/Sdk" + ) + sdk_dir="" + for candidate in "${sdk_candidates[@]}"; do + if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then + sdk_dir="$candidate" + break + fi + done + if [ -z "$sdk_dir" ]; then + echo "Android SDK not found on desktop-release host." + exit 1 + fi + echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV" + printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties + echo "Using Android SDK: $sdk_dir" + + - name: Set up Flutter + shell: bash + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else "$@"; fi + } + if ! command -v curl >/dev/null 2>&1; then + if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz + elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y curl git unzip + fi + fi + # Prefer pinned SDK already on the desktop-release host. + flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)" + flutter_version="${flutter_version:-3.47.0}" + candidates=( + "/home/yuna/.local/flutter-${flutter_version}" + "${HOME}/.cache/frosty-ci-flutter-${flutter_version}" + "/home/yuna/.local/flutter" + ) + flutter_home="" + for c in "${candidates[@]}"; do + if [ -x "${c}/bin/flutter" ]; then + flutter_home="$c" + break + fi + done + if [ -z "$flutter_home" ]; then + flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}" + rm -rf "${flutter_home}" + git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}" + fi + echo "${flutter_home}/bin" >> "$GITHUB_PATH" + echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV" + export PATH="${flutter_home}/bin:${PATH}" + flutter --version + flutter precache --android + + - name: Decode keystore + shell: bash + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then + echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD" + exit 1 + fi + printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks + test -s android/upload-keystore.jks + { + echo "storePassword=${STORE_PASSWORD}" + echo "keyPassword=${KEY_PASSWORD}" + echo "keyAlias=${KEY_ALIAS}" + echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks" + } > android/key.properties + echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)." + + - name: Resolve version metadata + id: version + shell: bash + run: | + set -euo pipefail + raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')" + VERSION_NAME="${raw%%+*}" + VERSION_CODE="${raw##*+}" + [ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1 + SHORT_SHA="${GITHUB_SHA::7}" + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + else + TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}" + fi + echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" + echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}" + + - name: Build signed release APK + id: build + shell: bash + env: + CLIENT_ID: ${{ secrets.CLIENT_ID }} + SECRET: ${{ secrets.SECRET }} + VERSION_NAME: ${{ steps.version.outputs.version_name }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + run: | + set -euo pipefail + if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then + echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)." + exit 1 + fi + flutter pub get + flutter build apk --release \ + --build-name="${VERSION_NAME}" \ + --build-number="${VERSION_CODE}" \ + --dart-define="CLIENT_ID=${CLIENT_ID}" \ + --dart-define="SECRET=${SECRET}" + mkdir -p dist + cp build/app/outputs/flutter-apk/app-release.apk \ + "dist/frosty-v${VERSION_NAME}.apk" + flutter build apk --release --split-per-abi \ + --build-name="${VERSION_NAME}" \ + --build-number="${VERSION_CODE}" \ + --dart-define="CLIENT_ID=${CLIENT_ID}" \ + --dart-define="SECRET=${SECRET}" || true + for abi in arm64-v8a armeabi-v7a x86_64; do + src="build/app/outputs/flutter-apk/app-${abi}-release.apk" + if [ -f "$src" ]; then + cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk" + fi + done + ls -lh dist/ + + - name: Publish Forgejo release + shell: bash + env: + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + VERSION_NAME: ${{ steps.version.outputs.version_name }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + SHORT_SHA: ${{ steps.version.outputs.short_sha }} + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else return 1; fi + } + for pkg in curl jq; do + command -v "$pkg" >/dev/null 2>&1 && continue + if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg" + elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg" + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y "$pkg" + fi + done + + API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}" + AUTH="Authorization: token ${FORGEJO_TOKEN}" + + BODY_FILE="$(mktemp)" + cat > "${BODY_FILE}" </dev/null + fi + test -n "$release_id" && test "$release_id" != "null" + + for apk in dist/*.apk; do + [ -f "$apk" ] || continue + name="$(basename "$apk")" + echo "Uploading ${name}…" + curl -sS -X POST -H "$AUTH" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${apk}" \ + "${API}/releases/${release_id}/assets?name=${name}" \ + | jq -r '.name // .message' + done + echo "Published ${TAG} as release ${release_id}" + rm -f "$BODY_FILE" diff --git a/docs/OBTAINIUM.md b/docs/OBTAINIUM.md new file mode 100644 index 00000000..1a342ef0 --- /dev/null +++ b/docs/OBTAINIUM.md @@ -0,0 +1,45 @@ +# Obtainium (Android) + +Forgejo fork: [copilot/frosty](https://forgejo.fsociety00.cc/copilot/frosty) (**private**). + +Releases: `.forgejo/workflows/release.yml` on runner `desktop-release` (tag `v*` or workflow_dispatch). + +## Obtainium settings + +| Setting | Value | +|---|---| +| Source | Forgejo | +| URL | `https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases` | +| APK filter | `frosty-arm64-v8a-v.*\.apk` | +| Trim version | `v?([0-9]+\.[0-9]+\.[0-9]+)` | +| Match group | `1` | + +Repo is private → add a Forgejo API token in Obtainium (same PAT as other Newas apps). + +Quick add: + +```text +obtainium://add/https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases +``` + +## Signing / Play Store conflict + +Package id stays `com.tommychow.frosty`. Play Store Frosty and this fork use **different signing keys**, so Obtainium cannot update stock → fork in place. Uninstall the Play build first, then install from Obtainium. + +## CI secrets (Forgejo repo) + +| Secret | Purpose | +|---|---| +| `RELEASE_KEYSTORE_BASE64` | Same pattern as Flow / Finamp / Jellyfin forks | +| `STORE_PASSWORD` | Keystore password | +| `KEY_ALIAS` | Key alias | +| `KEY_PASSWORD` | Key password | +| `CLIENT_ID` | Twitch Helix app client id (`--dart-define`) | +| `SECRET` | Twitch Helix app secret (`--dart-define`) | + +## Cut a release + +1. Bump `version:` in `pubspec.yaml` (`x.y.z+build`) if needed. +2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` + or run **Actions → Release APK → Run workflow**. +3. Install / update via Obtainium once the release asset appears. From a1b9a5e46d1639efc08edaf5b3c4c693bab7f802 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 11:34:44 +0200 Subject: [PATCH 51/67] Add script to mint and upload Forgejo Obtainium release secrets. Generates a local upload keystore and pushes Actions secrets without printing values, so Obtainium APK builds can be signed on desktop-release. Co-authored-by: Cursor --- scripts/setup-forgejo-release-secrets.sh | 235 +++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100755 scripts/setup-forgejo-release-secrets.sh diff --git a/scripts/setup-forgejo-release-secrets.sh b/scripts/setup-forgejo-release-secrets.sh new file mode 100755 index 00000000..325bb5a1 --- /dev/null +++ b/scripts/setup-forgejo-release-secrets.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Generate Android release signing material + upload Forgejo Actions secrets for +# Obtainium APK builds — without printing secret values. +# +# Usage (from anywhere): +# bash /home/yuna/git/frosty/scripts/setup-forgejo-release-secrets.sh +# +# What it does: +# 1. Writes secrets to ~/.config/frosty/forgejo-release-secrets.env (mode 600) +# — generates a new upload keystore + passwords if the file is missing +# — merges CLIENT_ID / SECRET from frosty/.env when present (never echoes them) +# 2. PUTs each secret to Forgejo Actions on copilot/frosty +# +# Re-run safely: existing secrets file is reused (keystore not regenerated) unless +# FORCE_REGENERATE=1 bash …/setup-forgejo-release-secrets.sh +# +# Twitch Helix: if CLIENT_ID/SECRET are still empty after merge, the script stops +# before upload and tells you to edit the secrets file (add the two lines) then re-run. +set -euo pipefail + +REPO="${REPO:-copilot/frosty}" +FORGEJO_URL="${FORGEJO_URL:-https://forgejo.fsociety00.cc}" +TOKEN_FILE="${TOKEN_FILE:-${HOME}/.config/forgejo/token}" +SECRETS_DIR="${SECRETS_DIR:-${HOME}/.config/frosty}" +SECRETS_FILE="${SECRETS_FILE:-${SECRETS_DIR}/forgejo-release-secrets.env}" +KEYSTORE_FILE="${KEYSTORE_FILE:-${SECRETS_DIR}/upload-keystore.jks}" +FROSTY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ENV_FILE:-${FROSTY_ROOT}/.env}" +KEY_ALIAS_DEFAULT="${KEY_ALIAS_DEFAULT:-upload}" + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "ERROR: missing command: $1" >&2 + exit 1 + } +} + +need_cmd curl +need_cmd python3 +need_cmd openssl +need_cmd keytool +need_cmd base64 + +if [[ ! -f "$TOKEN_FILE" ]]; then + echo "ERROR: Forgejo token not found: $TOKEN_FILE" >&2 + exit 1 +fi +TOKEN="$(tr -d '[:space:]' <"$TOKEN_FILE")" +[[ -n "$TOKEN" ]] || { + echo "ERROR: empty token in $TOKEN_FILE" >&2 + exit 1 +} + +mkdir -p "$SECRETS_DIR" +chmod 700 "$SECRETS_DIR" + +rand_pw() { + # URL/shell-safe password, no quotes + openssl rand -base64 24 | tr -d '\n/+=\r' | head -c 32 +} + +write_secrets_file() { + local store_pw key_pw alias b64 + store_pw="$1" + key_pw="$2" + alias="$3" + b64="$4" + # Atomic write; never echo values + local tmp + tmp="$(mktemp "${SECRETS_DIR}/.secrets.XXXXXX")" + umask 077 + cat >"$tmp" <&2 + # Response may contain error text only — still avoid dumping request body + python3 -c 'import pathlib; p=pathlib.Path("/tmp/frosty-forgejo-secret-resp.json"); print(p.read_text()[:500] if p.exists() else "")' >&2 || true + exit 1 + fi + echo " set ${name} (HTTP ${code})" +} + +# --- generate if needed --- +if [[ ! -f "$SECRETS_FILE" || "${FORCE_REGENERATE:-0}" == "1" ]]; then + echo "→ Generating new upload keystore + passwords → ${SECRETS_FILE}" + STORE_PW="$(rand_pw)" + KEY_PW="$(rand_pw)" + ALIAS="$KEY_ALIAS_DEFAULT" + rm -f "$KEYSTORE_FILE" + keytool -genkeypair \ + -keystore "$KEYSTORE_FILE" \ + -storetype JKS \ + -storepass "$STORE_PW" \ + -keypass "$KEY_PW" \ + -alias "$ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=Frosty Forgejo Release, OU=Newas, O=fsociety00, L=Home, ST=NA, C=DE" \ + >/dev/null 2>&1 + chmod 600 "$KEYSTORE_FILE" + B64="$(base64 -w0 "$KEYSTORE_FILE" 2>/dev/null || base64 "$KEYSTORE_FILE" | tr -d '\n')" + write_secrets_file "$STORE_PW" "$KEY_PW" "$ALIAS" "$B64" + # Drop locals + unset STORE_PW KEY_PW B64 + echo " wrote keystore ${KEYSTORE_FILE}" + echo " wrote secrets file (mode 600)" +else + echo "→ Reusing existing secrets file: ${SECRETS_FILE}" + echo " (set FORCE_REGENERATE=1 to mint a new keystore)" +fi + +echo "→ Merging Twitch CLIENT_ID/SECRET from ${ENV_FILE} if present…" +merge_twitch_from_dotenv || true + +if ! status="$(secrets_complete)"; then + echo >&2 + echo "Twitch Helix secrets still missing in ${SECRETS_FILE}." >&2 + echo "Edit that file and set CLIENT_ID=… and SECRET=… (from https://dev.twitch.tv/console)," >&2 + echo "or put them in ${ENV_FILE}, then re-run this script." >&2 + echo "Signing secrets are already generated; they will be uploaded together." >&2 + exit 3 +fi +echo "→ Secrets file complete (${status})" + +echo "→ Uploading Actions secrets to ${FORGEJO_URL}/${REPO} (values not printed)" +for name in RELEASE_KEYSTORE_BASE64 STORE_PASSWORD KEY_ALIAS KEY_PASSWORD CLIENT_ID SECRET; do + put_secret "$name" +done + +echo +echo "Done. Secrets live only in:" +echo " ${SECRETS_FILE}" +echo " ${KEYSTORE_FILE}" +echo " Forgejo Actions → ${REPO}" +echo +echo "Next: Forgejo → Actions → Release APK → Run workflow" +echo "Obtainium: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases" +echo "APK filter: frosty-arm64-v8a-v.*\\.apk" From 2775847c6d5da6c154e464e36c0391ad49b693e1 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 11:36:03 +0200 Subject: [PATCH 52/67] release v5.2.1 Ship the first Obtainium build of this Forgejo fork with TwitchAdSolutions ad blocking. Co-authored-by: Cursor --- assets/release-notes.md | 11 +++++++++++ pubspec.yaml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index df21d898..26387850 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,14 @@ +## v5.2.1 + +September 30, 2026 + +### New Features + +- Added TwitchAdSolutions-style ad blocking for the native player (and best-effort WebView inject), with a Settings toggle. +- Added Forgejo Obtainium release builds for this fork. + +--- + ## v5.2.0 August 16, 2026 diff --git a/pubspec.yaml b/pubspec.yaml index 9a54d21d..c5f510e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.0+70 +version: 5.2.1+71 environment: sdk: ^3.9.0 From 7863935fff2e9f66196f60c57a9237b8d756a728 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 11:36:53 +0200 Subject: [PATCH 53/67] ci: add temporary Actions probe workflow Verify Forgejo schedules jobs for this repo before Obtainium release builds. Co-authored-by: Cursor --- .forgejo/workflows/actions-probe.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .forgejo/workflows/actions-probe.yml diff --git a/.forgejo/workflows/actions-probe.yml b/.forgejo/workflows/actions-probe.yml new file mode 100644 index 00000000..a9e9414d --- /dev/null +++ b/.forgejo/workflows/actions-probe.yml @@ -0,0 +1,14 @@ +# Temporary probe: does this repo schedule Actions at all? +name: Actions probe + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + probe: + runs-on: desktop-release + steps: + - name: ping + run: echo "frosty actions probe ok" From d8247843c471eae771fcbcbc6ea372cc222b9a90 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 23:22:56 +0200 Subject: [PATCH 54/67] Add freeform tag search and viewer sort for live lists. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helix cannot filter by tags or sort ascending; use Twitch GQL for Search→Tags→streams and Most/Fewest viewers on Top, category, and tag screens. Co-authored-by: Cursor --- DESIGN.md | 9 + lib/apis/twitch_gql_api.dart | 169 +++++++++++++++++- .../channel/video/native_video_store.g.dart | 3 - lib/screens/home/search/search.dart | 19 +- .../home/search/search_results_tags.dart | 97 ++++++++++ lib/screens/home/search/search_store.dart | 15 +- lib/screens/home/search/search_store.g.dart | 20 +++ lib/screens/home/search/tag_streams.dart | 83 +++++++++ .../home/stream_list/stream_list_store.dart | 56 +++++- .../home/stream_list/stream_list_store.g.dart | 29 +++ .../home/stream_list/streams_list.dart | 23 ++- .../home/top/categories/category_streams.dart | 144 ++++++++------- lib/screens/home/top/top.dart | 37 +++- lib/widgets/stream_sort_button.dart | 38 ++++ pubspec.yaml | 2 +- 15 files changed, 655 insertions(+), 89 deletions(-) create mode 100644 lib/screens/home/search/search_results_tags.dart create mode 100644 lib/screens/home/search/tag_streams.dart create mode 100644 lib/widgets/stream_sort_button.dart diff --git a/DESIGN.md b/DESIGN.md index 882b4114..8151e954 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,6 +36,15 @@ Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player - Third-party proxies - HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) +## Tags + viewer sort (v5.2.2+) + +Helix cannot filter by freeform tags or sort ascending. GQL covers both: + +- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`) +- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton` + +Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. + ## Build Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index f72e7250..e6ac79fb 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -5,14 +5,47 @@ import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; +import 'package:frosty/models/stream.dart'; -/// Twitch GQL API client for fetching playback access tokens. +/// Viewer sort for GQL directory / tag stream lists. +enum StreamViewerSort { + viewersDesc, + viewersAsc; + + String get gqlValue => switch (this) { + StreamViewerSort.viewersDesc => 'VIEWER_COUNT', + StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC', + }; +} + +/// Twitch GQL API client for playback tokens and directory queries +/// (freeform tags + sorted streams) that Helix does not expose. /// /// Uses the Twitch web client ID (not the app's registered client ID) /// since the GQL endpoint requires it. class TwitchGqlApi extends BaseApiClient { TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); + static const _gqlHeaders = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + }; + + Future _gql(Map body) async { + final response = await post( + '/gql', + data: body, + headers: _gqlHeaders, + ); + final errors = response['errors']; + if (errors is List && errors.isNotEmpty && response['data'] == null) { + final message = (errors.first as JsonMap)['message'] as String? ?? + 'Twitch GQL error'; + throw ApiException(message); + } + return response; + } + /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -140,4 +173,138 @@ class TwitchGqlApi extends BaseApiClient { } return buffer.toString(); } + + /// Freeform tag name suggestions for search-as-you-type. + Future> searchFreeformTags({ + required String query, + int first = 20, + }) async { + if (query.trim().isEmpty) return const []; + + final response = await _gql({ + 'operationName': 'SearchFreeformTags', + 'query': + r'query SearchFreeformTags($query: String!, $first: Int) {' + r' searchFreeformTags(userQuery: $query, first: $first) {' + r' edges { node { tagName } } } }', + 'variables': {'query': query, 'first': first}, + }); + + final edges = + (((response['data'] as JsonMap?)?['searchFreeformTags'] + as JsonMap?)?['edges'] + as List?) ?? + const []; + + return edges + .map((edge) { + final node = (edge as JsonMap)['node'] as JsonMap?; + return node?['tagName'] as String?; + }) + .whereType() + .toList(); + } + + /// Live streams matching optional [freeformTags], sorted by viewer count. + Future getStreams({ + List? freeformTags, + StreamViewerSort sort = StreamViewerSort.viewersDesc, + String? cursor, + int first = 30, + }) async { + final response = await _gql({ + 'operationName': 'FrostyTopStreams', + 'query': + r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort) {' + r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } }', + 'variables': { + 'first': first, + 'after': cursor, + 'tags': freeformTags, + 'sort': sort.gqlValue, + }, + }); + + final connection = + (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; + return _streamsFromConnection(connection); + } + + /// Live streams under a category/game, with viewer sort (Helix has no asc). + Future getGameStreams({ + required String gameId, + StreamViewerSort sort = StreamViewerSort.viewersDesc, + String? cursor, + int first = 30, + }) async { + final response = await _gql({ + 'operationName': 'FrostyGameStreams', + 'query': + r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort) {' + r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } } }', + 'variables': { + 'id': gameId, + 'first': first, + 'after': cursor, + 'sort': sort.gqlValue, + }, + }); + + final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; + if (game == null) { + return const StreamsTwitch([], {}); + } + return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); + } + + StreamsTwitch _streamsFromConnection(JsonMap connection) { + final edges = (connection['edges'] as List?) ?? const []; + final pageInfo = connection['pageInfo'] as JsonMap?; + final hasNext = pageInfo?['hasNextPage'] as bool? ?? false; + + final streams = []; + String? nextCursor; + + for (final edge in edges) { + final edgeMap = edge as JsonMap; + nextCursor = edgeMap['cursor'] as String?; + final node = edgeMap['node'] as JsonMap?; + if (node == null) continue; + streams.add(_streamFromNode(node)); + } + + return StreamsTwitch( + streams, + hasNext && nextCursor != null ? {'cursor': nextCursor} : const {}, + ); + } + + StreamTwitch _streamFromNode(JsonMap node) { + final broadcaster = node['broadcaster'] as JsonMap? ?? const {}; + final game = node['game'] as JsonMap? ?? const {}; + final login = broadcaster['login'] as String? ?? ''; + + return StreamTwitch( + broadcaster['id'] as String? ?? '', + login, + broadcaster['displayName'] as String? ?? login, + game['id'] as String? ?? '', + game['name'] as String? ?? '', + node['title'] as String? ?? '', + (node['viewersCount'] as num?)?.toInt() ?? 0, + node['createdAt'] as String? ?? '', + // Helix-style placeholder so existing thumbnail widgets keep working. + 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', + ); + } } diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index 71fadd4f..afce5344 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -67,9 +67,6 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { return super._blockingAds; } - @override - bool get isBlockingAds => blockingAds; - @override bool get _blockingAds => blockingAds; diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index c5cdb719..c2fb3ab8 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -2,8 +2,10 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/home/search/search_results_categories.dart'; import 'package:frosty/screens/home/search/search_results_channels.dart'; +import 'package:frosty/screens/home/search/search_results_tags.dart'; import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -31,6 +33,7 @@ class _SearchState extends State { late final _searchStore = SearchStore( authStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), ); @override @@ -171,6 +174,20 @@ class _SearchState extends State { ), ), SearchResultsCategories(searchStore: _searchStore), + SliverToBoxAdapter( + child: Builder( + builder: (context) => SectionHeader( + 'Tags', + padding: EdgeInsets.fromLTRB( + 16 + MediaQuery.of(context).padding.left, + 8, + 16 + MediaQuery.of(context).padding.right, + 8, + ), + ), + ), + ), + SearchResultsTags(searchStore: _searchStore), // Add padding for bottom navigation bar const SliverBottomPadding(), ], @@ -204,7 +221,7 @@ class _SearchState extends State { onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), - hintText: 'Search channels or categories', + hintText: 'Search channels, categories, or tags', suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty diff --git a/lib/screens/home/search/search_results_tags.dart b/lib/screens/home/search/search_results_tags.dart new file mode 100644 index 00000000..4a445385 --- /dev/null +++ b/lib/screens/home/search/search_results_tags.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/home/search/search_store.dart'; +import 'package:frosty/screens/home/search/tag_streams.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:mobx/mobx.dart'; + +class SearchResultsTags extends StatelessWidget { + final SearchStore searchStore; + + const SearchResultsTags({super.key, required this.searchStore}); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final future = searchStore.tagFuture; + + if (future == null) { + if (searchStore.isSearching) { + return _tagSkeletons(); + } + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + switch (future.status) { + case FutureStatus.pending: + return _tagSkeletons(); + case FutureStatus.rejected: + return const SliverToBoxAdapter( + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'Unable to load tags', + vertical: true, + ), + ), + ); + case FutureStatus.fulfilled: + final List tags = future.result as List? ?? const []; + + if (tags.isEmpty) { + return const SliverToBoxAdapter( + child: SizedBox( + height: 100.0, + child: AlertMessage( + message: 'No matching tags', + vertical: true, + ), + ), + ); + } + + return SliverList.builder( + itemCount: tags.length, + itemBuilder: (context, index) { + final tag = tags[index]; + return ListTile( + leading: const Icon(Icons.tag_rounded), + title: Text(tag), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings( + name: TagStreams.routeName, + ), + builder: (context) => TagStreams(tagName: tag), + ), + ); + }, + ); + }, + ); + } + }, + ); + } + + Widget _tagSkeletons() { + return SliverList.builder( + itemCount: 4, + itemBuilder: (context, index) => ListTile( + leading: SkeletonLoader( + width: 24, + height: 24, + borderRadius: BorderRadius.circular(4), + ), + title: SkeletonLoader( + height: 16, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + } +} diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index e2d0509e..5969a495 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -17,6 +18,8 @@ abstract class SearchStoreBase with Store { final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; + final textEditingController = TextEditingController(); final textFieldFocusNode = FocusNode(); @@ -40,7 +43,14 @@ abstract class SearchStoreBase with Store { @readonly ObservableFuture? _categoryFuture; - SearchStoreBase({required this.authStore, required this.twitchApi}) { + @readonly + ObservableFuture>? _tagFuture; + + SearchStoreBase({ + required this.authStore, + required this.twitchApi, + required this.twitchGqlApi, + }) { init(); } @@ -75,6 +85,7 @@ abstract class SearchStoreBase with Store { _isSearching = false; _channelFuture = null; _categoryFuture = null; + _tagFuture = null; return; } @@ -127,6 +138,8 @@ abstract class SearchStoreBase with Store { } return categories; }).asObservable(); + + _tagFuture = twitchGqlApi.searchFreeformTags(query: query).asObservable(); } /// Find a specific channel provided the [query]. diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index ace3f885..ff03473e 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -109,6 +109,26 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } + late final _$_tagFutureAtom = Atom( + name: 'SearchStoreBase._tagFuture', + context: context, + ); + + ObservableFuture>? get tagFuture { + _$_tagFutureAtom.reportRead(); + return super._tagFuture; + } + + @override + ObservableFuture>? get _tagFuture => tagFuture; + + @override + set _tagFuture(ObservableFuture>? value) { + _$_tagFutureAtom.reportWrite(value, super._tagFuture, () { + super._tagFuture = value; + }); + } + late final _$initAsyncAction = AsyncAction( 'SearchStoreBase.init', context: context, diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart new file mode 100644 index 00000000..52b28194 --- /dev/null +++ b/lib/screens/home/search/tag_streams.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; +import 'package:frosty/screens/home/stream_list/streams_list.dart'; +import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; + +/// Streams matching a freeform tag (search → tap tag). +class TagStreams extends StatefulWidget { + static const routeName = 'TagStreams'; + + final String tagName; + + const TagStreams({super.key, required this.tagName}); + + @override + State createState() => _TagStreamsState(); +} + +class _TagStreamsState extends State { + var _sortOrder = StreamViewerSort.viewersDesc; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + extendBody: true, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + Positioned.fill( + child: StreamsList( + key: ValueKey(_sortOrder), + listType: ListType.tag, + freeformTag: widget.tagName, + sortOrder: _sortOrder, + showJumpButton: true, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: BlurredContainer( + gradientDirection: GradientDirection.up, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + left: MediaQuery.of(context).padding.left, + right: MediaQuery.of(context).padding.right, + ), + child: SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), + Expanded( + child: Text( + widget.tagName, + style: theme.textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => setState(() => _sortOrder = order), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 94efe2f1..64c6fb46 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/stream.dart'; @@ -23,12 +24,22 @@ abstract class ListStoreBase with Store { /// Twitch API service class for making requests. final TwitchApi twitchApi; + /// GQL client for tag streams and ascending viewer sort (Helix has neither). + final TwitchGqlApi? twitchGqlApi; + /// The type of list that this store is handling. final ListType listType; /// The category id to use when fetching streams if the [listType] is [ListType.category]. final String? categoryId; + /// Freeform tag name when [listType] is [ListType.tag]. + final String? freeformTag; + + /// Viewer sort for top / category / tag lists. + @observable + StreamViewerSort sortOrder; + /// The scroll controller used for handling scroll to top (if provided). /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. final ScrollController? scrollController; @@ -151,7 +162,10 @@ abstract class ListStoreBase with Store { required this.settingsStore, required this.twitchApi, required this.listType, + this.twitchGqlApi, this.categoryId, + this.freeformTag, + this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, }) { if (scrollController != null) { @@ -213,11 +227,36 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + // Helix is always viewer-desc; ascending needs GQL. + if (sortOrder == StreamViewerSort.viewersAsc && + twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getStreams( + sort: sortOrder, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + } break; case ListType.category: - newStreams = await twitchApi.getStreamsUnderCategory( - gameId: categoryId!, + if (sortOrder == StreamViewerSort.viewersAsc && + twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getGameStreams( + gameId: categoryId!, + sort: sortOrder, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getStreamsUnderCategory( + gameId: categoryId!, + cursor: _streamsCursor, + ); + } + break; + case ListType.tag: + newStreams = await twitchGqlApi!.getStreams( + freeformTags: [freeformTag!], + sort: sortOrder, cursor: _streamsCursor, ); break; @@ -245,6 +284,14 @@ abstract class ListStoreBase with Store { _isAllStreamsLoading = false; } + /// Changes viewer sort and reloads from the first page. + @action + Future setSortOrder(StreamViewerSort order) async { + if (sortOrder == order) return; + sortOrder = order; + await refreshStreams(); + } + @action Future getPinnedStreams() async { if (settingsStore.pinnedChannelIds.isEmpty) { @@ -359,4 +406,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -enum ListType { followed, top, category } +/// [ListType.tag] is the list of streams matching a freeform tag (GQL). +enum ListType { followed, top, category, tag } diff --git a/lib/screens/home/stream_list/stream_list_store.g.dart b/lib/screens/home/stream_list/stream_list_store.g.dart index 7fa78463..3ae70481 100644 --- a/lib/screens/home/stream_list/stream_list_store.g.dart +++ b/lib/screens/home/stream_list/stream_list_store.g.dart @@ -56,6 +56,24 @@ mixin _$ListStore on ListStoreBase, Store { name: 'ListStoreBase.offlineChannels', )).value; + late final _$sortOrderAtom = Atom( + name: 'ListStoreBase.sortOrder', + context: context, + ); + + @override + StreamViewerSort get sortOrder { + _$sortOrderAtom.reportRead(); + return super.sortOrder; + } + + @override + set sortOrder(StreamViewerSort value) { + _$sortOrderAtom.reportWrite(value, super.sortOrder, () { + super.sortOrder = value; + }); + } + late final _$_allStreamsAtom = Atom( name: 'ListStoreBase._allStreams', context: context, @@ -302,6 +320,16 @@ mixin _$ListStore on ListStoreBase, Store { return _$getStreamsAsyncAction.run(() => super.getStreams()); } + late final _$setSortOrderAsyncAction = AsyncAction( + 'ListStoreBase.setSortOrder', + context: context, + ); + + @override + Future setSortOrder(StreamViewerSort order) { + return _$setSortOrderAsyncAction.run(() => super.setSortOrder(order)); + } + late final _$getPinnedStreamsAsyncAction = AsyncAction( 'ListStoreBase.getPinnedStreams', context: context, @@ -349,6 +377,7 @@ mixin _$ListStore on ListStoreBase, Store { @override String toString() { return ''' +sortOrder: ${sortOrder}, showJumpButton: ${showJumpButton}, isOfflineChannelsExpanded: ${isOfflineChannelsExpanded}, hasMore: ${hasMore}, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index ac97721d..e784170b 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/stream_list/large_stream_card.dart'; import 'package:frosty/screens/home/stream_list/offline_channel_card.dart'; @@ -25,6 +26,12 @@ class StreamsList extends StatefulWidget { final String? categoryId; + /// Freeform tag when [listType] is [ListType.tag]. + final String? freeformTag; + + /// Viewer sort for top / category / tag lists. + final StreamViewerSort sortOrder; + /// The scroll controller to use for scroll to top functionality. final ScrollController? scrollController; @@ -34,6 +41,8 @@ class StreamsList extends StatefulWidget { super.key, required this.listType, this.categoryId, + this.freeformTag, + this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, this.showJumpButton = false, }); @@ -63,8 +72,11 @@ class _StreamsListState extends State authStore: context.read(), settingsStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), listType: widget.listType, categoryId: widget.categoryId, + freeformTag: widget.freeformTag, + sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, ); } @@ -95,6 +107,8 @@ class _StreamsListState extends State ? kToolbarHeight : widget.listType == ListType.category ? kToolbarHeight + 122 + : widget.listType == ListType.tag + ? kToolbarHeight : 0.0; final topPadding = MediaQuery.of(context).padding.top + extraTopPadding; @@ -209,9 +223,12 @@ class _StreamsListState extends State ); } else { statusWidget = AlertMessage( - message: widget.listType == ListType.followed - ? 'No followed streams' - : 'No top streams', + message: switch (widget.listType) { + ListType.followed => 'No followed streams', + ListType.tag => 'No streams with this tag', + ListType.category => 'No streams in this category', + ListType.top => 'No top streams', + }, vertical: true, ); } diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index fd1fc0c2..69d165f1 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; @@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; import 'package:provider/provider.dart'; /// A widget that displays a list of streams under the provided [categoryId]. @@ -25,11 +27,14 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { + var _sortOrder = StreamViewerSort.viewersDesc; + late final _listStore = ListStore( listType: ListType.category, categoryId: widget.categoryId, authStore: context.read(), twitchApi: context.read(), + twitchGqlApi: context.read(), settingsStore: context.read(), ); @@ -46,8 +51,10 @@ class _CategoryStreamsState extends State { // Stream list content behind the pinned elements Positioned.fill( child: StreamsList( + key: ValueKey(_sortOrder), listType: ListType.category, categoryId: widget.categoryId, + sortOrder: _sortOrder, showJumpButton: true, ), ), @@ -64,75 +71,80 @@ class _CategoryStreamsState extends State { right: MediaQuery.of(context).padding.right, ), child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // App bar section - SizedBox( - height: kToolbarHeight, - child: Row( - children: [ - IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, + mainAxisSize: MainAxisSize.min, + children: [ + // App bar section + SizedBox( + height: kToolbarHeight, + child: Row( + children: [ + IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ), + const Spacer(), + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => + setState(() => _sortOrder = order), + ), + ], + ), + ), + // Category card section + Observer( + builder: (_) { + if (_listStore.categoryDetails != null) { + return _TransparentCategoryCard( + category: _listStore.categoryDetails!, + ); + } else { + // Skeleton loader for category card + return Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, ), - ], - ), - ), - // Category card section - Observer( - builder: (_) { - if (_listStore.categoryDetails != null) { - return _TransparentCategoryCard( - category: _listStore.categoryDetails!, - ); - } else { - // Skeleton loader for category card - return Padding( - padding: const EdgeInsets.only( - bottom: 16, - left: 16, - right: 16, - ), - child: Row( - spacing: 12, - children: [ - const SizedBox( - width: 80, - child: AspectRatio( - aspectRatio: 3 / 4, - child: SkeletonLoader( - borderRadius: kCardBorderRadius, + child: Row( + spacing: 12, + children: [ + const SizedBox( + width: 80, + child: AspectRatio( + aspectRatio: 3 / 4, + child: SkeletonLoader( + borderRadius: kCardBorderRadius, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + SkeletonLoader( + height: 20, + width: double.infinity, + borderRadius: BorderRadius.circular(4), ), - ), + SkeletonLoader( + height: 16, + width: 120, + borderRadius: BorderRadius.circular(4), + ), + ], ), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 8, - children: [ - SkeletonLoader( - height: 20, - width: double.infinity, - borderRadius: BorderRadius.circular(4), - ), - SkeletonLoader( - height: 16, - width: 120, - borderRadius: BorderRadius.circular(4), - ), - ], - ), - ), - ], - ), - ); - } - }, - ), - ], - ), + ), + ], + ), + ); + } + }, + ), + ], + ), ), ), ], diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 8fb8c9a5..467ead4d 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/top/categories/categories.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/stream_sort_button.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -17,17 +19,21 @@ class TopSection extends StatefulWidget { class _TopSectionState extends State with SingleTickerProviderStateMixin { late final _tabBarController = TabController(length: 2, vsync: this); + var _sortOrder = StreamViewerSort.viewersDesc; @override void initState() { super.initState(); - _tabBarController.addListener( - () => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, - ); + _tabBarController.addListener(() { + widget.homeStore.topSectionCurrentIndex = _tabBarController.index; + setState(() {}); + }); } @override Widget build(BuildContext context) { + final showSort = _tabBarController.index == 0; + return Stack( children: [ Positioned.fill( @@ -35,7 +41,9 @@ class _TopSectionState extends State controller: _tabBarController, children: [ StreamsList( + key: ValueKey(_sortOrder), listType: ListType.top, + sortOrder: _sortOrder, scrollController: widget.homeStore.topSectionScrollControllers[0], ), @@ -57,12 +65,23 @@ class _TopSectionState extends State left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right, ), - child: TabBar( - isScrollable: true, - controller: _tabBarController, - tabs: const [ - Tab(child: Text('Streams')), - Tab(child: Text('Categories')), + child: Row( + children: [ + Expanded( + child: TabBar( + isScrollable: true, + controller: _tabBarController, + tabs: const [ + Tab(child: Text('Streams')), + Tab(child: Text('Categories')), + ], + ), + ), + if (showSort) + StreamSortButton( + sortOrder: _sortOrder, + onChanged: (order) => setState(() => _sortOrder = order), + ), ], ), ), diff --git a/lib/widgets/stream_sort_button.dart b/lib/widgets/stream_sort_button.dart new file mode 100644 index 00000000..883fc363 --- /dev/null +++ b/lib/widgets/stream_sort_button.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; + +/// Toggles viewer-count sort between descending (default) and ascending. +class StreamSortButton extends StatelessWidget { + final StreamViewerSort sortOrder; + final ValueChanged onChanged; + + const StreamSortButton({ + super.key, + required this.sortOrder, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final isAsc = sortOrder == StreamViewerSort.viewersAsc; + + return PopupMenuButton( + tooltip: 'Sort by viewers', + icon: Icon( + isAsc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, + ), + initialValue: sortOrder, + onSelected: onChanged, + itemBuilder: (context) => [ + const PopupMenuItem( + value: StreamViewerSort.viewersDesc, + child: Text('Most viewers'), + ), + const PopupMenuItem( + value: StreamViewerSort.viewersAsc, + child: Text('Fewest viewers'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c5f510e7..76941826 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.1+71 +version: 5.2.2+72 environment: sdk: ^3.9.0 From cff29232c7ccc0dea250e3411e802347c665fd1f Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 30 Sep 2026 23:25:33 +0200 Subject: [PATCH 55/67] Fix NativeVideoStore isBlockingAds for release builds. build_runner had stripped a hand-patched override from the generated store; implement the VideoPlayerInterface getter on the hand-written base class so assembleRelease succeeds. Co-authored-by: Cursor --- lib/screens/channel/video/native_video_store.dart | 6 ++++++ pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 28911fd4..1af97005 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -131,6 +131,12 @@ abstract class NativeVideoStoreBase @readonly var _blockingAds = false; + /// Exposes [_blockingAds] via [VideoPlayerInterface.isBlockingAds] + /// (must live in the hand-written store — build_runner will strip a + /// hand-patched override from the `.g.dart`). + @override + bool get isBlockingAds => _blockingAds; + @readonly var _paused = true; diff --git a/pubspec.yaml b/pubspec.yaml index 76941826..67d176c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.2+72 +version: 5.2.3+73 environment: sdk: ^3.9.0 From 9d392d8d6a004d12ab2cc85d7d487eb558303eca Mon Sep 17 00:00:00 2001 From: copilot Date: Thu, 1 Oct 2026 10:51:10 +0200 Subject: [PATCH 56/67] Add stream language filter and fix directory infinite scroll. Settings multi-select filters Top/category/tag lists via Helix (with over-fetch for multiple languages). Larger Helix pages, safer cursors, and scroll near-end loading restore pagination; GQL page-2 integrity failures end the list cleanly. Co-authored-by: Cursor --- DESIGN.md | 10 ++ lib/apis/twitch_api.dart | 93 +++++++++++++-- lib/apis/twitch_gql_api.dart | 67 ++++++++--- lib/models/stream.dart | 27 ++++- lib/models/stream.g.dart | 1 + lib/models/stream_languages.dart | 50 +++++++++ .../home/stream_list/stream_list_store.dart | 106 +++++++++++++++--- .../home/stream_list/streams_list.dart | 7 +- lib/screens/settings/general_settings.dart | 45 +++++++- .../settings/stores/settings_store.dart | 9 ++ .../settings/stores/settings_store.g.dart | 25 +++++ pubspec.yaml | 2 +- 12 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 lib/models/stream_languages.dart diff --git a/DESIGN.md b/DESIGN.md index 8151e954..ba1df2ad 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -45,6 +45,16 @@ Helix cannot filter by freeform tags or sort ascending. GQL covers both: Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. +## Stream languages (v5.2.4+) + +Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. + +- One language: Helix `language=` (paginates cleanly, `first=100`) +- Several languages: Helix over-fetch + client filter (still paginated) +- Tags / ascending sort: GQL with `languages: [Language!]`; page-2 often hits Twitch integrity — treated as end-of-list after the first page of 30 + +Also fixed infinite scroll: larger Helix pages, defensive cursors, scroll near-end load-more, and stop disposing shared Home scroll controllers. + ## Build Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 148b7efa..23cec738 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -175,11 +175,23 @@ class TwitchApi extends BaseApiClient { } } - /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. - Future getTopStreams({String? cursor}) async { + /// Returns a [StreamsTwitch] object that contains top streams and a cursor for further requests. + /// + /// [language] is an optional ISO 639-1 code (Helix accepts only one). + Future getTopStreams({ + String? cursor, + String? language, + int first = 100, + }) async { + final queryParams = { + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; + final data = await get( '/streams', - queryParameters: cursor != null ? {'after': cursor} : null, + queryParameters: queryParams, ); return StreamsTwitch.fromJson(data); @@ -190,7 +202,7 @@ class TwitchApi extends BaseApiClient { required String id, String? cursor, }) async { - final queryParams = {'user_id': id}; + final queryParams = {'user_id': id, 'first': '100'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -206,7 +218,7 @@ class TwitchApi extends BaseApiClient { required String userId, String? cursor, }) async { - final queryParams = {'user_id': userId, 'first': '20'}; + final queryParams = {'user_id': userId, 'first': '100'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( @@ -221,15 +233,82 @@ class TwitchApi extends BaseApiClient { Future getStreamsUnderCategory({ required String gameId, String? cursor, + String? language, + int first = 100, }) async { - final queryParams = {'game_id': gameId}; - if (cursor != null) queryParams['after'] = cursor; + final queryParams = { + 'game_id': gameId, + 'first': first, + if (cursor != null) 'after': cursor, + if (language != null && language.isNotEmpty) 'language': language, + }; final data = await get('/streams', queryParameters: queryParams); return StreamsTwitch.fromJson(data); } + /// Fetches Helix stream pages until [minCount] streams match [languages] + /// (or the directory is exhausted). Preserves Helix viewer order. + /// + /// Used when the user selected more than one language — Helix only accepts a + /// single `language` query param. + Future getStreamsMatchingLanguages({ + required List languages, + String? gameId, + String? cursor, + int minCount = 40, + int maxPages = 8, + }) async { + final wanted = languages + .map((l) => l.trim().toLowerCase()) + .where((l) => l.isNotEmpty) + .toSet(); + if (wanted.isEmpty) { + return gameId == null + ? getTopStreams(cursor: cursor) + : getStreamsUnderCategory(gameId: gameId, cursor: cursor); + } + if (wanted.length == 1) { + final lang = wanted.first; + return gameId == null + ? getTopStreams(cursor: cursor, language: lang) + : getStreamsUnderCategory( + gameId: gameId, + cursor: cursor, + language: lang, + ); + } + + final matched = []; + String? pageCursor = cursor; + String? nextCursor; + var pages = 0; + + while (matched.length < minCount && pages < maxPages) { + final page = gameId == null + ? await getTopStreams(cursor: pageCursor) + : await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor); + pages++; + nextCursor = page.pagination['cursor']; + for (final stream in page.data) { + if (wanted.contains(stream.language.toLowerCase())) { + matched.add(stream); + } + } + if (nextCursor == null || page.data.isEmpty) { + nextCursor = null; + break; + } + pageCursor = nextCursor; + } + + return StreamsTwitch( + matched, + nextCursor != null ? {'cursor': nextCursor} : const {}, + ); + } + /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({required String userLogin}) async { final data = await get( diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index e6ac79fb..beb88194 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -38,10 +38,16 @@ class TwitchGqlApi extends BaseApiClient { headers: _gqlHeaders, ); final errors = response['errors']; - if (errors is List && errors.isNotEmpty && response['data'] == null) { - final message = (errors.first as JsonMap)['message'] as String? ?? - 'Twitch GQL error'; - throw ApiException(message); + if (errors is List && errors.isNotEmpty) { + final data = response['data']; + final dataEmpty = data == null || + (data is Map && + data.values.every((value) => value == null)); + if (dataEmpty) { + final message = (errors.first as JsonMap)['message'] as String? ?? + 'Twitch GQL error'; + throw ApiException(message); + } } return response; } @@ -205,28 +211,42 @@ class TwitchGqlApi extends BaseApiClient { .toList(); } - /// Live streams matching optional [freeformTags], sorted by viewer count. + /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. + /// + /// Twitch caps `first` at 30 for this connection. Cursor pagination often + /// fails with `IntegrityCheckFailed` without a web integrity token — callers + /// should treat a failed page-2 as end-of-list. Future getStreams({ List? freeformTags, + List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, }) async { + final cappedFirst = first.clamp(1, 30); + final gqlLangs = languages == null || languages.isEmpty + ? null + : languages + .map((l) => l.trim().toUpperCase()) + .where((l) => l.isNotEmpty) + .toList(); + final response = await _gql({ 'operationName': 'FrostyTopStreams', 'query': - r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort) {' - r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags }) {' + r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' + r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' r' edges { cursor node {' - r' id title viewersCount createdAt' + r' id title viewersCount createdAt language' r' game { id name }' r' broadcaster { id login displayName }' r' } } pageInfo { hasNextPage } } }', 'variables': { - 'first': first, + 'first': cappedFirst, 'after': cursor, 'tags': freeformTags, 'sort': sort.gqlValue, + 'langs': gqlLangs, }, }); @@ -235,28 +255,38 @@ class TwitchGqlApi extends BaseApiClient { return _streamsFromConnection(connection); } - /// Live streams under a category/game, with viewer sort (Helix has no asc). + /// Live streams under a category/game, with viewer sort and optional languages. Future getGameStreams({ required String gameId, + List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, }) async { + final cappedFirst = first.clamp(1, 30); + final gqlLangs = languages == null || languages.isEmpty + ? null + : languages + .map((l) => l.trim().toUpperCase()) + .where((l) => l.isNotEmpty) + .toList(); + final response = await _gql({ 'operationName': 'FrostyGameStreams', 'query': - r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort) {' - r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort }) {' + r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' + r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' r' edges { cursor node {' - r' id title viewersCount createdAt' + r' id title viewersCount createdAt language' r' game { id name }' r' broadcaster { id login displayName }' r' } } pageInfo { hasNextPage } } } }', 'variables': { 'id': gameId, - 'first': first, + 'first': cappedFirst, 'after': cursor, 'sort': sort.gqlValue, + 'langs': gqlLangs, }, }); @@ -270,7 +300,8 @@ class TwitchGqlApi extends BaseApiClient { StreamsTwitch _streamsFromConnection(JsonMap connection) { final edges = (connection['edges'] as List?) ?? const []; final pageInfo = connection['pageInfo'] as JsonMap?; - final hasNext = pageInfo?['hasNextPage'] as bool? ?? false; + // Prefer explicit pageInfo; if missing, assume more when we got a full page. + final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30; final streams = []; String? nextCursor; @@ -285,7 +316,9 @@ class TwitchGqlApi extends BaseApiClient { return StreamsTwitch( streams, - hasNext && nextCursor != null ? {'cursor': nextCursor} : const {}, + hasNext && nextCursor != null && nextCursor.isNotEmpty + ? {'cursor': nextCursor} + : const {}, ); } @@ -293,6 +326,7 @@ class TwitchGqlApi extends BaseApiClient { final broadcaster = node['broadcaster'] as JsonMap? ?? const {}; final game = node['game'] as JsonMap? ?? const {}; final login = broadcaster['login'] as String? ?? ''; + final language = (node['language'] as String? ?? '').toLowerCase(); return StreamTwitch( broadcaster['id'] as String? ?? '', @@ -305,6 +339,7 @@ class TwitchGqlApi extends BaseApiClient { node['createdAt'] as String? ?? '', // Helix-style placeholder so existing thumbnail widgets keep working. 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', + language, ); } } diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 0f433ff6..8e09ffed 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -15,6 +15,10 @@ class StreamTwitch { final String startedAt; final String thumbnailUrl; + /// ISO 639-1 broadcast language from Helix (`de`, `en`, …). Empty when unknown. + @JsonKey(defaultValue: '') + final String language; + const StreamTwitch( this.userId, this.userLogin, @@ -24,8 +28,9 @@ class StreamTwitch { this.title, this.viewerCount, this.startedAt, - this.thumbnailUrl, - ); + this.thumbnailUrl, [ + this.language = '', + ]); factory StreamTwitch.fromJson(Map json) => _$StreamTwitchFromJson(json); @@ -38,6 +43,20 @@ class StreamsTwitch { const StreamsTwitch(this.data, this.pagination); - factory StreamsTwitch.fromJson(Map json) => - _$StreamsTwitchFromJson(json); + factory StreamsTwitch.fromJson(Map json) { + final rawPagination = json['pagination']; + final pagination = {}; + if (rawPagination is Map) { + final cursor = rawPagination['cursor']; + if (cursor is String && cursor.isNotEmpty) { + pagination['cursor'] = cursor; + } + } + return StreamsTwitch( + (json['data'] as List) + .map((e) => StreamTwitch.fromJson(e as Map)) + .toList(), + pagination, + ); + } } diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index fe2fe565..4d012271 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -16,6 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( (json['viewer_count'] as num).toInt(), json['started_at'] as String, json['thumbnail_url'] as String, + json['language'] as String? ?? '', ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/models/stream_languages.dart b/lib/models/stream_languages.dart new file mode 100644 index 00000000..455ccc60 --- /dev/null +++ b/lib/models/stream_languages.dart @@ -0,0 +1,50 @@ +/// ISO 639-1 codes offered in Settings for stream language filtering. +/// +/// Stored lowercase (Helix). GQL `Language` enums are the uppercase form. +class StreamLanguages { + StreamLanguages._(); + + static const all = <({String code, String label})>[ + (code: 'de', label: 'Deutsch'), + (code: 'en', label: 'English'), + (code: 'es', label: 'Español'), + (code: 'fr', label: 'Français'), + (code: 'it', label: 'Italiano'), + (code: 'pt', label: 'Português'), + (code: 'pl', label: 'Polski'), + (code: 'ru', label: 'Русский'), + (code: 'tr', label: 'Türkçe'), + (code: 'nl', label: 'Nederlands'), + (code: 'sv', label: 'Svenska'), + (code: 'no', label: 'Norsk'), + (code: 'da', label: 'Dansk'), + (code: 'fi', label: 'Suomi'), + (code: 'cs', label: 'Čeština'), + (code: 'hu', label: 'Magyar'), + (code: 'ro', label: 'Română'), + (code: 'uk', label: 'Українська'), + (code: 'ja', label: '日本語'), + (code: 'ko', label: '한국어'), + (code: 'zh', label: '中文'), + (code: 'ar', label: 'العربية'), + (code: 'th', label: 'ไทย'), + (code: 'vi', label: 'Tiếng Việt'), + (code: 'id', label: 'Bahasa Indonesia'), + (code: 'hi', label: 'हिन्दी'), + (code: 'other', label: 'Other'), + ]; + + static String labelFor(String code) { + for (final entry in all) { + if (entry.code == code) return entry.label; + } + return code; + } + + /// Helix / settings codes → GQL `Language` enum values. + static List toGqlEnums(Iterable codes) => codes + .map((c) => c.trim().toLowerCase()) + .where((c) => c.isNotEmpty) + .map((c) => c.toUpperCase()) + .toList(); +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 64c6fb46..8dfde208 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -44,6 +44,10 @@ abstract class ListStoreBase with Store { /// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar. final ScrollController? scrollController; + /// When true, [dispose] will dispose [scrollController]. Shared controllers + /// from Home must not be disposed here. + final bool ownsScrollController; + /// The pagination cursor for the streams. String? _streamsCursor; @@ -64,6 +68,12 @@ abstract class ListStoreBase with Store { _isCategoryDetailsLoading || _isOfflineChannelsLoading; + /// Selected stream language codes from settings (lowercase ISO 639-1). + List get _languageFilter => settingsStore.streamLanguages + .map((l) => l.trim().toLowerCase()) + .where((l) => l.isNotEmpty) + .toList(); + /// The loading status for pagination. /// The list of the fetched streams. @@ -156,6 +166,7 @@ abstract class ListStoreBase with Store { ReactionDisposer? _pinnedStreamsReactionDisposer; ReactionDisposer? _offlineChannelsExpansionDisposer; + ReactionDisposer? _languageFilterReactionDisposer; ListStoreBase({ required this.authStore, @@ -167,16 +178,10 @@ abstract class ListStoreBase with Store { this.freeformTag, this.sortOrder = StreamViewerSort.viewersDesc, this.scrollController, + this.ownsScrollController = false, }) { if (scrollController != null) { - scrollController!.addListener(() { - if (scrollController!.position.atEdge || - scrollController!.position.outOfRange) { - showJumpButton = false; - } else { - showJumpButton = true; - } - }); + scrollController!.addListener(_onScroll); } if (listType == ListType.followed) { @@ -209,15 +214,44 @@ abstract class ListStoreBase with Store { _getCategoryDetails(); } + // Reload directory lists when the language filter changes. + if (listType != ListType.followed) { + _languageFilterReactionDisposer = reaction( + (_) => settingsStore.streamLanguages.toList(), + (_) => refreshStreams(), + ); + } + getStreams(); } + void _onScroll() { + final controller = scrollController; + if (controller == null || !controller.hasClients) return; + + final position = controller.position; + if (position.atEdge || position.outOfRange) { + showJumpButton = false; + } else { + showJumpButton = true; + } + + // Backup pagination trigger (in addition to SliverList near-end builds). + if (hasMore && + position.pixels >= position.maxScrollExtent - 800) { + getStreams(); + } + } + /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { + if (_isAllStreamsLoading) return; + _isAllStreamsLoading = true; try { + final languages = _languageFilter; final StreamsTwitch newStreams; switch (listType) { case ListType.followed: @@ -227,15 +261,19 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - // Helix is always viewer-desc; ascending needs GQL. + // Ascending sort needs GQL (Helix is always desc). if (sortOrder == StreamViewerSort.viewersAsc && twitchGqlApi != null) { newStreams = await twitchGqlApi!.getStreams( sort: sortOrder, + languages: languages.isEmpty ? null : languages, cursor: _streamsCursor, ); } else { - newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor); + newStreams = await twitchApi.getStreamsMatchingLanguages( + languages: languages, + cursor: _streamsCursor, + ); } break; case ListType.category: @@ -244,10 +282,12 @@ abstract class ListStoreBase with Store { newStreams = await twitchGqlApi!.getGameStreams( gameId: categoryId!, sort: sortOrder, + languages: languages.isEmpty ? null : languages, cursor: _streamsCursor, ); } else { - newStreams = await twitchApi.getStreamsUnderCategory( + newStreams = await twitchApi.getStreamsMatchingLanguages( + languages: languages, gameId: categoryId!, cursor: _streamsCursor, ); @@ -257,31 +297,59 @@ abstract class ListStoreBase with Store { newStreams = await twitchGqlApi!.getStreams( freeformTags: [freeformTag!], sort: sortOrder, + languages: languages.isEmpty ? null : languages, cursor: _streamsCursor, ); break; } + // GQL page-2 often returns empty `data` under IntegrityCheckFailed + // without throwing — treat empty continuation pages as end-of-list. + final isContinuation = _streamsCursor != null; + if (isContinuation && newStreams.data.isEmpty) { + _streamsCursor = null; + _error = null; + return; + } + if (_streamsCursor == null) { _allStreams = newStreams.data.asObservable(); } else { - _allStreams.addAll(newStreams.data); + // Dedupe in case of overlapping over-fetch pages. + final existingIds = _allStreams.map((s) => s.userId).toSet(); + _allStreams.addAll( + newStreams.data.where((s) => !existingIds.contains(s.userId)), + ); } - _streamsCursor = newStreams.pagination['cursor']; + final next = newStreams.pagination['cursor']; + _streamsCursor = (next != null && next.isNotEmpty) ? next : null; _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; debugPrint('Streams SocketException: No internet connection'); + // Stop retry storms on failed continuations. + if (_streamsCursor != null && _allStreams.isNotEmpty) { + _streamsCursor = null; + } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); + if (_streamsCursor != null && _allStreams.isNotEmpty) { + _streamsCursor = null; + // Don't surface integrity / page-2 failures as a full-list error. + if (_allStreams.isNotEmpty) _error = null; + } } catch (e) { _error = 'Something went wrong loading streams'; debugPrint('Streams error: $e'); + if (_streamsCursor != null && _allStreams.isNotEmpty) { + _streamsCursor = null; + _error = null; + } + } finally { + _isAllStreamsLoading = false; } - - _isAllStreamsLoading = false; } /// Changes viewer sort and reloads from the first page. @@ -396,8 +464,14 @@ abstract class ListStoreBase with Store { void dispose() { _pinnedStreamsReactionDisposer?.call(); _offlineChannelsExpansionDisposer?.call(); + _languageFilterReactionDisposer?.call(); - scrollController?.dispose(); + if (scrollController != null) { + scrollController!.removeListener(_onScroll); + if (ownsScrollController) { + scrollController!.dispose(); + } + } } } diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index e784170b..e24e6812 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -78,13 +78,18 @@ class _StreamsListState extends State freeformTag: widget.freeformTag, sortOrder: widget.sortOrder, scrollController: widget.scrollController ?? _scrollController, + ownsScrollController: widget.scrollController == null && widget.showJumpButton, ); } @override void dispose() { - _scrollController?.dispose(); + // ListStore disposes the controller when it owns it. + if (!_listStore.ownsScrollController) { + _scrollController?.dispose(); + } WidgetsBinding.instance.removeObserver(this); + _listStore.dispose(); super.dispose(); } diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 6b13f477..96d5f8c9 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; -// import removed: flutter_colorpicker import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/models/stream_languages.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/widgets/accent_color_setting.dart'; import 'package:frosty/widgets/external_browser_setting.dart'; -// import removed: frosty/widgets/dialog.dart import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/settings_page_layout.dart'; import 'package:frosty/widgets/theme_selection_setting.dart'; @@ -34,6 +33,48 @@ class GeneralSettings extends StatelessWidget { value: settingsStore.showThumbnails, onChanged: (newValue) => settingsStore.showThumbnails = newValue, ), + const SectionHeader('Stream languages'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + settingsStore.streamLanguages.isEmpty + ? 'Showing all languages. Select one or more to filter Top, category, and tag lists (like Twitch).' + : 'Only streams in the selected languages appear in Top, category, and tag lists.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in StreamLanguages.all) + FilterChip( + label: Text(entry.label), + selected: settingsStore.streamLanguages.contains(entry.code), + onSelected: (selected) { + final next = [...settingsStore.streamLanguages]; + if (selected) { + if (!next.contains(entry.code)) next.add(entry.code); + } else { + next.remove(entry.code); + } + settingsStore.streamLanguages = next; + }, + ), + ], + ), + ), + if (settingsStore.streamLanguages.isNotEmpty) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => + settingsStore.streamLanguages = const [], + child: const Text('Clear language filter'), + ), + ), const SectionHeader('Links'), ExternalBrowserSetting(settingsStore: settingsStore), ], diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 30599f99..3c2a3583 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -52,6 +52,14 @@ abstract class _SettingsStoreBase with Store { @observable var launchUrlExternal = defaultLaunchUrlExternal; + /// ISO 639-1 language codes for Top / category / tag stream lists. + /// Empty = show all languages (no filter). Default Deutsch like a DE Twitch account. + static const defaultStreamLanguages = ['de']; + + @JsonKey(defaultValue: defaultStreamLanguages) + @observable + var streamLanguages = defaultStreamLanguages; + @action void resetGeneralSettings() { themeType = defaultThemeType; @@ -61,6 +69,7 @@ abstract class _SettingsStoreBase with Store { showThumbnails = defaultShowThumbnails; launchUrlExternal = defaultLaunchUrlExternal; + streamLanguages = defaultStreamLanguages; } // * Video Settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index a583ac94..be512a98 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -20,6 +20,11 @@ SettingsStore _$SettingsStoreFromJson( ..showThumbnails = json['showThumbnails'] as bool? ?? true ..largeStreamCard = json['largeStreamCard'] as bool? ?? false ..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false + ..streamLanguages = + (json['streamLanguages'] as List?) + ?.map((e) => e as String) + .toList() ?? + ['de'] ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true @@ -104,6 +109,7 @@ Map _$SettingsStoreToJson( 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, 'launchUrlExternal': instance.launchUrlExternal, + 'streamLanguages': instance.streamLanguages, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, @@ -278,6 +284,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$streamLanguagesAtom = Atom( + name: '_SettingsStoreBase.streamLanguages', + context: context, + ); + + @override + List get streamLanguages { + _$streamLanguagesAtom.reportRead(); + return super.streamLanguages; + } + + @override + set streamLanguages(List value) { + _$streamLanguagesAtom.reportWrite(value, super.streamLanguages, () { + super.streamLanguages = value; + }); + } + late final _$showVideoAtom = Atom( name: '_SettingsStoreBase.showVideo', context: context, @@ -1261,6 +1285,7 @@ accentColor: ${accentColor}, showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, +streamLanguages: ${streamLanguages}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, diff --git a/pubspec.yaml b/pubspec.yaml index 67d176c7..75525dc5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.3+73 +version: 5.2.4+74 environment: sdk: ^3.9.0 From 47150d0ab31f337df4ad9708853c2321158e3b0d Mon Sep 17 00:00:00 2001 From: copilot Date: Thu, 1 Oct 2026 11:41:25 +0200 Subject: [PATCH 57/67] Fix tag pagination and persist viewer sort. Load tag (and language) directories via Helix with client-side tag matching so scroll can continue past the GQL integrity wall. Re-sort lists locally after each fetch and store streamViewerSort in Settings. Co-authored-by: Cursor --- DESIGN.md | 8 +- lib/apis/twitch_api.dart | 76 +++++++++++---- lib/apis/twitch_gql_api.dart | 1 + lib/models/stream.dart | 5 + lib/models/stream.g.dart | 1 + lib/screens/home/search/tag_streams.dart | 38 ++++---- .../home/stream_list/stream_list_store.dart | 96 ++++++++++--------- .../home/top/categories/category_streams.dart | 28 +++--- lib/screens/home/top/top.dart | 28 +++--- .../settings/stores/settings_store.dart | 12 +++ .../settings/stores/settings_store.g.dart | 32 +++++++ pubspec.yaml | 2 +- 12 files changed, 214 insertions(+), 113 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ba1df2ad..25adaf1d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,11 +49,9 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. -- One language: Helix `language=` (paginates cleanly, `first=100`) -- Several languages: Helix over-fetch + client filter (still paginated) -- Tags / ascending sort: GQL with `languages: [Language!]`; page-2 often hits Twitch integrity — treated as end-of-list after the first page of 30 - -Also fixed infinite scroll: larger Helix pages, defensive cursors, scroll near-end load-more, and stop disposing shared Home scroll controllers. +- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter +- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable) +- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity ## Build diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 23cec738..a1a7e4c2 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -248,29 +248,42 @@ class TwitchApi extends BaseApiClient { return StreamsTwitch.fromJson(data); } - /// Fetches Helix stream pages until [minCount] streams match [languages] - /// (or the directory is exhausted). Preserves Helix viewer order. + /// Fetches Helix directory pages until enough streams match [languages] and + /// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// - /// Used when the user selected more than one language — Helix only accepts a - /// single `language` query param. - Future getStreamsMatchingLanguages({ - required List languages, + /// Helix is always viewer-desc and supports at most one `language` query + /// param; tags are filtered client-side. Prefer this over GQL for pagination + /// (GQL page-2 requires a non-bot Client-Integrity token). + Future getDirectoryStreams({ + List languages = const [], String? gameId, + String? freeformTag, String? cursor, - int minCount = 40, - int maxPages = 8, + int minCount = 50, + int maxPages = 12, }) async { - final wanted = languages + final wantedLangs = languages .map((l) => l.trim().toLowerCase()) .where((l) => l.isNotEmpty) .toSet(); - if (wanted.isEmpty) { - return gameId == null - ? getTopStreams(cursor: cursor) - : getStreamsUnderCategory(gameId: gameId, cursor: cursor); + final tagNeedle = freeformTag?.trim().toLowerCase(); + final hasTag = tagNeedle != null && tagNeedle.isNotEmpty; + + bool matches(StreamTwitch stream) { + if (wantedLangs.isNotEmpty && + !wantedLangs.contains(stream.language.toLowerCase())) { + return false; + } + if (hasTag) { + final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle); + if (!hit) return false; + } + return true; } - if (wanted.length == 1) { - final lang = wanted.first; + + // Fast path: no tag filter and 0–1 languages → one Helix call. + if (!hasTag && wantedLangs.length <= 1) { + final lang = wantedLangs.isEmpty ? null : wantedLangs.first; return gameId == null ? getTopStreams(cursor: cursor, language: lang) : getStreamsUnderCategory( @@ -280,6 +293,11 @@ class TwitchApi extends BaseApiClient { ); } + // When filtering by tag and exactly one language, still pass language to + // Helix so each page is denser in matches. + final helixLanguage = + wantedLangs.length == 1 ? wantedLangs.first : null; + final matched = []; String? pageCursor = cursor; String? nextCursor; @@ -287,14 +305,16 @@ class TwitchApi extends BaseApiClient { while (matched.length < minCount && pages < maxPages) { final page = gameId == null - ? await getTopStreams(cursor: pageCursor) - : await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor); + ? await getTopStreams(cursor: pageCursor, language: helixLanguage) + : await getStreamsUnderCategory( + gameId: gameId, + cursor: pageCursor, + language: helixLanguage, + ); pages++; nextCursor = page.pagination['cursor']; for (final stream in page.data) { - if (wanted.contains(stream.language.toLowerCase())) { - matched.add(stream); - } + if (matches(stream)) matched.add(stream); } if (nextCursor == null || page.data.isEmpty) { nextCursor = null; @@ -309,6 +329,22 @@ class TwitchApi extends BaseApiClient { ); } + /// Deprecated name — use [getDirectoryStreams]. + Future getStreamsMatchingLanguages({ + required List languages, + String? gameId, + String? cursor, + int minCount = 40, + int maxPages = 8, + }) => + getDirectoryStreams( + languages: languages, + gameId: gameId, + cursor: cursor, + minCount: minCount, + maxPages: maxPages, + ); + /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({required String userLogin}) async { final data = await get( diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index beb88194..1f6f228f 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -340,6 +340,7 @@ class TwitchGqlApi extends BaseApiClient { // Helix-style placeholder so existing thumbnail widgets keep working. 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', language, + const [], ); } } diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 8e09ffed..34a35c60 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -19,6 +19,10 @@ class StreamTwitch { @JsonKey(defaultValue: '') final String language; + /// Freeform tags from Helix (e.g. `Deutsch`, `English`). + @JsonKey(defaultValue: []) + final List tags; + const StreamTwitch( this.userId, this.userLogin, @@ -30,6 +34,7 @@ class StreamTwitch { this.startedAt, this.thumbnailUrl, [ this.language = '', + this.tags = const [], ]); factory StreamTwitch.fromJson(Map json) => diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 4d012271..95f9bff3 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -17,6 +17,7 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( json['started_at'] as String, json['thumbnail_url'] as String, json['language'] as String? ?? '', + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/screens/home/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart index 52b28194..c78c9051 100644 --- a/lib/screens/home/search/tag_streams.dart +++ b/lib/screens/home/search/tag_streams.dart @@ -1,28 +1,24 @@ import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/stream_sort_button.dart'; +import 'package:provider/provider.dart'; /// Streams matching a freeform tag (search → tap tag). -class TagStreams extends StatefulWidget { +class TagStreams extends StatelessWidget { static const routeName = 'TagStreams'; final String tagName; const TagStreams({super.key, required this.tagName}); - @override - State createState() => _TagStreamsState(); -} - -class _TagStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; - @override Widget build(BuildContext context) { final theme = Theme.of(context); + final settingsStore = context.watch(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, @@ -31,12 +27,13 @@ class _TagStreamsState extends State { body: Stack( children: [ Positioned.fill( - child: StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.tag, - freeformTag: widget.tagName, - sortOrder: _sortOrder, - showJumpButton: true, + child: Observer( + builder: (_) => StreamsList( + listType: ListType.tag, + freeformTag: tagName, + sortOrder: settingsStore.streamViewerSort, + showJumpButton: true, + ), ), ), Positioned( @@ -61,15 +58,18 @@ class _TagStreamsState extends State { ), Expanded( child: Text( - widget.tagName, + tagName, style: theme.textTheme.titleLarge, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: settingsStore.streamViewerSort, + onChanged: (order) => + settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 8dfde208..a1631949 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -214,11 +214,18 @@ abstract class ListStoreBase with Store { _getCategoryDetails(); } - // Reload directory lists when the language filter changes. + // Reload directory lists when the language filter or sort changes. if (listType != ListType.followed) { + sortOrder = settingsStore.streamViewerSort; _languageFilterReactionDisposer = reaction( - (_) => settingsStore.streamLanguages.toList(), - (_) => refreshStreams(), + (_) => [ + ...settingsStore.streamLanguages, + settingsStore.streamViewerSort, + ], + (_) { + sortOrder = settingsStore.streamViewerSort; + refreshStreams(); + }, ); } @@ -252,6 +259,14 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; + final isAsc = sortOrder == StreamViewerSort.viewersAsc; + // Asc needs a deeper Helix scan so client-side re-sort can surface + // lower-viewer streams (Helix itself is always viewer-desc). + final minCount = isAsc ? 80 : 50; + final maxPages = isAsc + ? (_streamsCursor == null ? 20 : 10) + : (_streamsCursor == null ? 12 : 8); + final StreamsTwitch newStreams; switch (listType) { case ListType.followed: @@ -261,50 +276,36 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - // Ascending sort needs GQL (Helix is always desc). - if (sortOrder == StreamViewerSort.viewersAsc && - twitchGqlApi != null) { - newStreams = await twitchGqlApi!.getStreams( - sort: sortOrder, - languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, - ); - } else { - newStreams = await twitchApi.getStreamsMatchingLanguages( - languages: languages, - cursor: _streamsCursor, - ); - } + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + cursor: _streamsCursor, + minCount: minCount, + maxPages: maxPages, + ); break; case ListType.category: - if (sortOrder == StreamViewerSort.viewersAsc && - twitchGqlApi != null) { - newStreams = await twitchGqlApi!.getGameStreams( - gameId: categoryId!, - sort: sortOrder, - languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, - ); - } else { - newStreams = await twitchApi.getStreamsMatchingLanguages( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - ); - } + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + gameId: categoryId!, + cursor: _streamsCursor, + minCount: minCount, + maxPages: maxPages, + ); break; case ListType.tag: - newStreams = await twitchGqlApi!.getStreams( - freeformTags: [freeformTag!], - sort: sortOrder, - languages: languages.isEmpty ? null : languages, + // Helix + client tag filter (GQL freeformTags can't paginate without + // a non-bot Client-Integrity token). + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + freeformTag: freeformTag, cursor: _streamsCursor, + minCount: minCount, + maxPages: maxPages, ); break; } - // GQL page-2 often returns empty `data` under IntegrityCheckFailed - // without throwing — treat empty continuation pages as end-of-list. + // Empty continuation ⇒ end of directory. final isContinuation = _streamsCursor != null; if (isContinuation && newStreams.data.isEmpty) { _streamsCursor = null; @@ -315,7 +316,6 @@ abstract class ListStoreBase with Store { if (_streamsCursor == null) { _allStreams = newStreams.data.asObservable(); } else { - // Dedupe in case of overlapping over-fetch pages. final existingIds = _allStreams.map((s) => s.userId).toSet(); _allStreams.addAll( newStreams.data.where((s) => !existingIds.contains(s.userId)), @@ -324,11 +324,11 @@ abstract class ListStoreBase with Store { final next = newStreams.pagination['cursor']; _streamsCursor = (next != null && next.isNotEmpty) ? next : null; + _applyViewerSort(); _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; debugPrint('Streams SocketException: No internet connection'); - // Stop retry storms on failed continuations. if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; } @@ -337,7 +337,6 @@ abstract class ListStoreBase with Store { debugPrint('Streams ApiException: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - // Don't surface integrity / page-2 failures as a full-list error. if (_allStreams.isNotEmpty) _error = null; } } catch (e) { @@ -352,12 +351,19 @@ abstract class ListStoreBase with Store { } } - /// Changes viewer sort and reloads from the first page. + void _applyViewerSort() { + final sorted = _allStreams.toList(); + sorted.sort((a, b) { + final cmp = a.viewerCount.compareTo(b.viewerCount); + return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp; + }); + _allStreams = sorted.asObservable(); + } + + /// Persists viewer sort and reloads via the settings reaction. @action Future setSortOrder(StreamViewerSort order) async { - if (sortOrder == order) return; - sortOrder = order; - await refreshStreams(); + settingsStore.streamViewerSort = order; } @action diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index 69d165f1..d5a1edf0 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -27,7 +27,7 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; + late final _settingsStore = context.read(); late final _listStore = ListStore( listType: ListType.category, @@ -35,7 +35,8 @@ class _CategoryStreamsState extends State { authStore: context.read(), twitchApi: context.read(), twitchGqlApi: context.read(), - settingsStore: context.read(), + settingsStore: _settingsStore, + sortOrder: _settingsStore.streamViewerSort, ); @override @@ -50,12 +51,13 @@ class _CategoryStreamsState extends State { children: [ // Stream list content behind the pinned elements Positioned.fill( - child: StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.category, - categoryId: widget.categoryId, - sortOrder: _sortOrder, - showJumpButton: true, + child: Observer( + builder: (_) => StreamsList( + listType: ListType.category, + categoryId: widget.categoryId, + sortOrder: _settingsStore.streamViewerSort, + showJumpButton: true, + ), ), ), // Single blurred background spanning app bar and category card @@ -84,10 +86,12 @@ class _CategoryStreamsState extends State { onPressed: Navigator.of(context).pop, ), const Spacer(), - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => - setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: _settingsStore.streamViewerSort, + onChanged: (order) => + _settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 467ead4d..5f4d51a1 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/top/categories/categories.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/stream_sort_button.dart'; +import 'package:provider/provider.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -19,7 +21,6 @@ class TopSection extends StatefulWidget { class _TopSectionState extends State with SingleTickerProviderStateMixin { late final _tabBarController = TabController(length: 2, vsync: this); - var _sortOrder = StreamViewerSort.viewersDesc; @override void initState() { @@ -32,6 +33,7 @@ class _TopSectionState extends State @override Widget build(BuildContext context) { + final settingsStore = context.watch(); final showSort = _tabBarController.index == 0; return Stack( @@ -40,12 +42,13 @@ class _TopSectionState extends State child: TabBarView( controller: _tabBarController, children: [ - StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.top, - sortOrder: _sortOrder, - scrollController: - widget.homeStore.topSectionScrollControllers[0], + Observer( + builder: (_) => StreamsList( + listType: ListType.top, + sortOrder: settingsStore.streamViewerSort, + scrollController: + widget.homeStore.topSectionScrollControllers[0], + ), ), Categories( scrollController: @@ -78,9 +81,12 @@ class _TopSectionState extends State ), ), if (showSort) - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: settingsStore.streamViewerSort, + onChanged: (order) => + settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 3c2a3583..031b2c53 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -1,3 +1,4 @@ +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:mobx/mobx.dart'; @@ -60,6 +61,16 @@ abstract class _SettingsStoreBase with Store { @observable var streamLanguages = defaultStreamLanguages; + /// Persistent viewer sort for Top / category / tag directories. + static const defaultStreamViewerSort = StreamViewerSort.viewersDesc; + + @JsonKey( + defaultValue: defaultStreamViewerSort, + unknownEnumValue: StreamViewerSort.viewersDesc, + ) + @observable + var streamViewerSort = defaultStreamViewerSort; + @action void resetGeneralSettings() { themeType = defaultThemeType; @@ -70,6 +81,7 @@ abstract class _SettingsStoreBase with Store { launchUrlExternal = defaultLaunchUrlExternal; streamLanguages = defaultStreamLanguages; + streamViewerSort = defaultStreamViewerSort; } // * Video Settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index be512a98..797941cf 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -25,6 +25,13 @@ SettingsStore _$SettingsStoreFromJson( ?.map((e) => e as String) .toList() ?? ['de'] + ..streamViewerSort = + $enumDecodeNullable( + _$StreamViewerSortEnumMap, + json['streamViewerSort'], + unknownValue: StreamViewerSort.viewersDesc, + ) ?? + StreamViewerSort.viewersDesc ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true @@ -110,6 +117,7 @@ Map _$SettingsStoreToJson( 'largeStreamCard': instance.largeStreamCard, 'launchUrlExternal': instance.launchUrlExternal, 'streamLanguages': instance.streamLanguages, + 'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, @@ -165,6 +173,11 @@ const _$ThemeTypeEnumMap = { ThemeType.dark: 'dark', }; +const _$StreamViewerSortEnumMap = { + StreamViewerSort.viewersDesc: 'viewersDesc', + StreamViewerSort.viewersAsc: 'viewersAsc', +}; + const _$TimestampTypeEnumMap = { TimestampType.disabled: 'disabled', TimestampType.twelve: 'twelve', @@ -302,6 +315,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$streamViewerSortAtom = Atom( + name: '_SettingsStoreBase.streamViewerSort', + context: context, + ); + + @override + StreamViewerSort get streamViewerSort { + _$streamViewerSortAtom.reportRead(); + return super.streamViewerSort; + } + + @override + set streamViewerSort(StreamViewerSort value) { + _$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () { + super.streamViewerSort = value; + }); + } + late final _$showVideoAtom = Atom( name: '_SettingsStoreBase.showVideo', context: context, @@ -1286,6 +1317,7 @@ showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, streamLanguages: ${streamLanguages}, +streamViewerSort: ${streamViewerSort}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, diff --git a/pubspec.yaml b/pubspec.yaml index 75525dc5..45974ec4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.4+74 +version: 5.2.5+75 environment: sdk: ^3.9.0 From 5a787e0134b6518a86098a5b6d453ce2428d6ace Mon Sep 17 00:00:00 2001 From: copilot Date: Thu, 1 Oct 2026 12:03:12 +0200 Subject: [PATCH 58/67] Fix fewest-viewers sort via GQL VIEWER_COUNT_ASC. Helix directories are viewer-desc only; local reverse of top pages wrongly showed high-viewer streams for ascending sort. Co-authored-by: Cursor --- DESIGN.md | 6 +- assets/release-notes.md | 10 ++ .../home/stream_list/stream_list_store.dart | 94 +++++++++++-------- pubspec.yaml | 2 +- 4 files changed, 68 insertions(+), 44 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 25adaf1d..6be25c9d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -50,8 +50,10 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter -- Viewer sort is persisted (`streamViewerSort`); lists are re-sorted client-side after each fetch (Twitch GQL `VIEWER_COUNT_ASC` is unreliable) -- Tag directories use Helix `tags[]` matching (not GQL) so infinite scroll works without Client-Integrity +- Viewer sort is persisted (`streamViewerSort`) +- **Most viewers (desc):** Helix directory order (paginates) +- **Fewest viewers (asc):** Twitch GQL `VIEWER_COUNT_ASC` at request time (Helix cannot ascend; local reverse of Helix top pages was wrong) +- Tag directories (desc): Helix `tags[]` matching for scroll; asc uses GQL with the same tag/language filters ## Build diff --git a/assets/release-notes.md b/assets/release-notes.md index 26387850..8af12647 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.2.6 + +October 1, 2026 + +### Bug Fixes + +- Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally. + +--- + ## v5.2.1 September 30, 2026 diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index a1631949..bc05259b 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -260,12 +260,6 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; final isAsc = sortOrder == StreamViewerSort.viewersAsc; - // Asc needs a deeper Helix scan so client-side re-sort can surface - // lower-viewer streams (Helix itself is always viewer-desc). - final minCount = isAsc ? 80 : 50; - final maxPages = isAsc - ? (_streamsCursor == null ? 20 : 10) - : (_streamsCursor == null ? 12 : 8); final StreamsTwitch newStreams; switch (listType) { @@ -276,36 +270,56 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + if (isAsc && twitchGqlApi != null) { + // Helix is always viewer-desc — fewest must be requested via GQL. + newStreams = await twitchGqlApi!.getStreams( + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + cursor: _streamsCursor, + ); + } break; case ListType.category: - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + if (isAsc && twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getGameStreams( + gameId: categoryId!, + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + cursor: _streamsCursor, + ); + } else { + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + gameId: categoryId!, + cursor: _streamsCursor, + ); + } break; case ListType.tag: - // Helix + client tag filter (GQL freeformTags can't paginate without - // a non-bot Client-Integrity token). - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - freeformTag: freeformTag, - cursor: _streamsCursor, - minCount: minCount, - maxPages: maxPages, - ); + if (isAsc && twitchGqlApi != null) { + newStreams = await twitchGqlApi!.getStreams( + freeformTags: [freeformTag!], + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + cursor: _streamsCursor, + ); + } else { + // Desc: Helix + tag filter (GQL page-2 needs Client-Integrity). + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + freeformTag: freeformTag, + cursor: _streamsCursor, + ); + } break; } - // Empty continuation ⇒ end of directory. + // Empty continuation ⇒ end of directory (incl. GQL integrity dead-ends). final isContinuation = _streamsCursor != null; if (isContinuation && newStreams.data.isEmpty) { _streamsCursor = null; @@ -313,18 +327,25 @@ abstract class ListStoreBase with Store { return; } + // Keep API order. Only sanitize each ASC page — Twitch ASC can briefly + // mis-order within a page; never re-sort a Helix desc directory locally. + final pageData = isAsc + ? (List.of(newStreams.data)..sort( + (a, b) => a.viewerCount.compareTo(b.viewerCount), + )) + : newStreams.data; + if (_streamsCursor == null) { - _allStreams = newStreams.data.asObservable(); + _allStreams = pageData.asObservable(); } else { final existingIds = _allStreams.map((s) => s.userId).toSet(); _allStreams.addAll( - newStreams.data.where((s) => !existingIds.contains(s.userId)), + pageData.where((s) => !existingIds.contains(s.userId)), ); } final next = newStreams.pagination['cursor']; _streamsCursor = (next != null && next.isNotEmpty) ? next : null; - _applyViewerSort(); _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; @@ -351,15 +372,6 @@ abstract class ListStoreBase with Store { } } - void _applyViewerSort() { - final sorted = _allStreams.toList(); - sorted.sort((a, b) { - final cmp = a.viewerCount.compareTo(b.viewerCount); - return sortOrder == StreamViewerSort.viewersAsc ? cmp : -cmp; - }); - _allStreams = sorted.asObservable(); - } - /// Persists viewer sort and reloads via the settings reaction. @action Future setSortOrder(StreamViewerSort order) async { diff --git a/pubspec.yaml b/pubspec.yaml index 45974ec4..7b28e0fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.5+75 +version: 5.2.6+76 environment: sdk: ^3.9.0 From 318dc84dc57267c916f81f996c86f10de156a648 Mon Sep 17 00:00:00 2001 From: copilot Date: Thu, 1 Oct 2026 12:23:15 +0200 Subject: [PATCH 59/67] Fix tag infinite scroll after GQL ASC first page. GQL page-2 fails integrity, so fewest starts with GQL then continues via Helix tag filter with dedupe; empty Helix windows no longer end the list while a cursor remains. Co-authored-by: Cursor --- DESIGN.md | 6 +- assets/release-notes.md | 10 ++ lib/apis/twitch_api.dart | 18 +++- .../home/stream_list/stream_list_store.dart | 102 ++++++++++++++---- pubspec.yaml | 2 +- 5 files changed, 111 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6be25c9d..3ad3817c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,9 +51,9 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter - Viewer sort is persisted (`streamViewerSort`) -- **Most viewers (desc):** Helix directory order (paginates) -- **Fewest viewers (asc):** Twitch GQL `VIEWER_COUNT_ASC` at request time (Helix cannot ascend; local reverse of Helix top pages was wrong) -- Tag directories (desc): Helix `tags[]` matching for scroll; asc uses GQL with the same tag/language filters +- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match) +- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails) +- Tag directories never rely on GQL cursors for load-more ## Build diff --git a/assets/release-notes.md b/assets/release-notes.md index 8af12647..28d3b3e9 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.2.7 + +October 1, 2026 + +### Bug Fixes + +- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page. + +--- + ## v5.2.6 October 1, 2026 diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index a1a7e4c2..f4bd8152 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -254,6 +254,10 @@ class TwitchApi extends BaseApiClient { /// Helix is always viewer-desc and supports at most one `language` query /// param; tags are filtered client-side. Prefer this over GQL for pagination /// (GQL page-2 requires a non-bot Client-Integrity token). + /// + /// When a [freeformTag] filter yields no matches in a Helix window, this + /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so + /// callers can continue — an empty match set alone is not end-of-list. Future getDirectoryStreams({ List languages = const [], String? gameId, @@ -293,6 +297,10 @@ class TwitchApi extends BaseApiClient { ); } + // Sparse tags need a deeper Helix scan per request. + final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount; + final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages; + // When filtering by tag and exactly one language, still pass language to // Helix so each page is denser in matches. final helixLanguage = @@ -303,7 +311,7 @@ class TwitchApi extends BaseApiClient { String? nextCursor; var pages = 0; - while (matched.length < minCount && pages < maxPages) { + while (matched.length < effectiveMin && pages < effectiveMax) { final page = gameId == null ? await getTopStreams(cursor: pageCursor, language: helixLanguage) : await getStreamsUnderCategory( @@ -316,7 +324,9 @@ class TwitchApi extends BaseApiClient { for (final stream in page.data) { if (matches(stream)) matched.add(stream); } - if (nextCursor == null || page.data.isEmpty) { + if (nextCursor == null || + nextCursor.isEmpty || + page.data.isEmpty) { nextCursor = null; break; } @@ -325,7 +335,9 @@ class TwitchApi extends BaseApiClient { return StreamsTwitch( matched, - nextCursor != null ? {'cursor': nextCursor} : const {}, + nextCursor != null && nextCursor.isNotEmpty + ? {'cursor': nextCursor} + : const {}, ); } diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index bc05259b..f8e97b69 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -51,6 +51,10 @@ abstract class ListStoreBase with Store { /// The pagination cursor for the streams. String? _streamsCursor; + /// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity + /// fails). Helix starts from the top; duplicates are skipped. + var _ascHelixFill = false; + /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -59,7 +63,8 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed - bool get hasMore => isLoading == false && _streamsCursor != null; + bool get hasMore => + isLoading == false && (_streamsCursor != null || _ascHelixFill); @computed bool get isLoading => @@ -260,6 +265,13 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; final isAsc = sortOrder == StreamViewerSort.viewersAsc; + // GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed. + // First empty list → GQL ASC once; further pages → Helix fill + dedupe. + final gqlAscFirstPage = isAsc && + twitchGqlApi != null && + !_ascHelixFill && + _allStreams.isEmpty && + listType != ListType.followed; final StreamsTwitch newStreams; switch (listType) { @@ -270,72 +282,88 @@ abstract class ListStoreBase with Store { ); break; case ListType.top: - if (isAsc && twitchGqlApi != null) { - // Helix is always viewer-desc — fewest must be requested via GQL. + if (gqlAscFirstPage) { newStreams = await twitchGqlApi!.getStreams( sort: StreamViewerSort.viewersAsc, languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, ); } else { newStreams = await twitchApi.getDirectoryStreams( languages: languages, cursor: _streamsCursor, + minCount: isAsc ? 30 : 50, + maxPages: isAsc ? 20 : 12, ); } break; case ListType.category: - if (isAsc && twitchGqlApi != null) { + if (gqlAscFirstPage) { newStreams = await twitchGqlApi!.getGameStreams( gameId: categoryId!, sort: StreamViewerSort.viewersAsc, languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, ); } else { newStreams = await twitchApi.getDirectoryStreams( languages: languages, gameId: categoryId!, cursor: _streamsCursor, + minCount: isAsc ? 30 : 50, + maxPages: isAsc ? 20 : 12, ); } break; case ListType.tag: - if (isAsc && twitchGqlApi != null) { + if (gqlAscFirstPage) { newStreams = await twitchGqlApi!.getStreams( freeformTags: [freeformTag!], sort: StreamViewerSort.viewersAsc, languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, ); } else { - // Desc: Helix + tag filter (GQL page-2 needs Client-Integrity). + // Desc, and ASC after GQL: Helix + tag filter (paginates). newStreams = await twitchApi.getDirectoryStreams( languages: languages, freeformTag: freeformTag, cursor: _streamsCursor, + minCount: 40, + maxPages: 25, ); } break; } - // Empty continuation ⇒ end of directory (incl. GQL integrity dead-ends). - final isContinuation = _streamsCursor != null; - if (isContinuation && newStreams.data.isEmpty) { + final next = newStreams.pagination['cursor']; + final hasHelixCursor = next != null && next.isNotEmpty; + + // Empty Helix window with cursor left ⇒ keep scanning (sparse tags). + final isHelixContinuation = + _streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty); + if (isHelixContinuation && + newStreams.data.isEmpty && + !gqlAscFirstPage) { + if (hasHelixCursor) { + _streamsCursor = next; + _error = null; + _isAllStreamsLoading = false; + await getStreams(); + return; + } _streamsCursor = null; + _ascHelixFill = false; _error = null; return; } - // Keep API order. Only sanitize each ASC page — Twitch ASC can briefly - // mis-order within a page; never re-sort a Helix desc directory locally. + // Keep API order for desc. ASC: sanitize page, then full-list sort after + // Helix fill so newly found streams land in fewest-first order. final pageData = isAsc ? (List.of(newStreams.data)..sort( (a, b) => a.viewerCount.compareTo(b.viewerCount), )) : newStreams.data; - if (_streamsCursor == null) { + if (_allStreams.isEmpty && !_ascHelixFill) { _allStreams = pageData.asObservable(); } else { final existingIds = _allStreams.map((s) => s.userId).toSet(); @@ -343,8 +371,25 @@ abstract class ListStoreBase with Store { pageData.where((s) => !existingIds.contains(s.userId)), ); } - final next = newStreams.pagination['cursor']; - _streamsCursor = (next != null && next.isNotEmpty) ? next : null; + + if (isAsc && _allStreams.length > 1) { + final sorted = _allStreams.toList() + ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); + _allStreams = sorted.asObservable(); + } + + if (gqlAscFirstPage) { + // Ignore GQL cursor — page-2 integrity fails. Fill via Helix next. + _ascHelixFill = true; + _streamsCursor = null; + } else { + _streamsCursor = hasHelixCursor ? next : null; + if (_streamsCursor == null) { + _ascHelixFill = false; + } else if (isAsc) { + _ascHelixFill = true; + } + } _error = null; } on SocketException { @@ -352,19 +397,31 @@ abstract class ListStoreBase with Store { debugPrint('Streams SocketException: No internet connection'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; + _ascHelixFill = false; } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { + if (_allStreams.isNotEmpty) { + // GQL page-2 integrity or mid-scroll error: fall back to Helix fill. + if (isAscDirectory && !_ascHelixFill) { + _ascHelixFill = true; + _streamsCursor = null; + _error = null; + _isAllStreamsLoading = false; + await getStreams(); + return; + } _streamsCursor = null; - if (_allStreams.isNotEmpty) _error = null; + _ascHelixFill = false; + _error = null; } } catch (e) { _error = 'Something went wrong loading streams'; debugPrint('Streams error: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; + _ascHelixFill = false; _error = null; } } finally { @@ -372,6 +429,10 @@ abstract class ListStoreBase with Store { } } + bool get isAscDirectory => + sortOrder == StreamViewerSort.viewersAsc && + listType != ListType.followed; + /// Persists viewer sort and reloads via the settings reaction. @action Future setSortOrder(StreamViewerSort order) async { @@ -453,6 +514,7 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; + _ascHelixFill = false; await getStreams(); } @@ -498,5 +560,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is the list of streams matching a freeform tag (GQL). +/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page). enum ListType { followed, top, category, tag } diff --git a/pubspec.yaml b/pubspec.yaml index 7b28e0fb..8e1a5656 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.6+76 +version: 5.2.7+77 environment: sdk: ^3.9.0 From 30848e6fe8284a439632e9b75ca447f39deecb38 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 03:06:05 +0200 Subject: [PATCH 60/67] Add follow and unfollow for live and offline channels. Long-press actions use Helix for follow status and web GQL mutations via the linked session. Co-authored-by: Cursor --- lib/apis/base_api_client.dart | 7 + lib/apis/twitch_api.dart | 19 ++ lib/apis/twitch_gql_api.dart | 162 +++++++++++++ lib/main.dart | 5 +- .../home/stream_list/large_stream_card.dart | 6 + .../stream_list/offline_channel_card.dart | 6 + lib/screens/home/stream_list/stream_card.dart | 6 + .../home/stream_list/streams_list.dart | 20 ++ lib/screens/settings/stores/auth_store.dart | 10 +- lib/screens/settings/stores/user_store.dart | 127 ++++++++++ lib/screens/settings/stores/user_store.g.dart | 36 +++ lib/widgets/user_actions_modal.dart | 218 ++++++++++++++++-- test/apis/twitch_api_test.dart | 51 ++++ test/apis/twitch_gql_follow_test.dart | 72 ++++++ .../settings/stores/user_store_test.dart | 131 +++++++++-- 15 files changed, 835 insertions(+), 41 deletions(-) create mode 100644 test/apis/twitch_gql_follow_test.dart diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index 4fd3745c..87163d84 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -44,6 +44,13 @@ class UnauthorizedException extends ApiException { : super(message, 401, DioExceptionType.badResponse); } +/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token. +class IntegrityCheckException extends ApiException { + const IntegrityCheckException([ + String message = 'Twitch integrity check failed', + ]) : super(message); +} + /// Base API client to eliminate code duplication across API services abstract class BaseApiClient { final Dio _dio; diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index f4bd8152..ca9f49f8 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -229,6 +229,25 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } + /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete + /// follows were removed from the public API). + Future isFollowingChannel({ + required String userId, + required String broadcasterId, + }) async { + final data = await get( + '/channels/followed', + queryParameters: { + 'user_id': userId, + 'broadcaster_id': broadcasterId, + 'first': '1', + }, + ); + + final list = data['data'] as List? ?? const []; + return list.isNotEmpty; + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 1f6f228f..0fce183a 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -297,6 +297,168 @@ class TwitchGqlApi extends BaseApiClient { return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); } + /// Persisted hashes used by the Twitch web Follow button (same as Xtra). + static const _followUserHash = + '800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08'; + static const _unfollowUserHash = + 'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880'; + + /// Follows [targetId] using the web GQL session. Helix no longer supports this. + /// + /// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts + /// need a [integrityToken] + matching [deviceId]; callers should retry after + /// [fetchIntegrityToken] when [IntegrityCheckException] is thrown. + Future followUser({ + required String targetId, + required String authToken, + bool disableNotifications = false, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_FollowUser', + sha256Hash: _followUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': { + 'disableNotifications': disableNotifications, + 'targetID': targetId, + }, + }, + successPath: const ['followUser'], + ); + } + + /// Unfollows [targetId] using the web GQL session. + Future unfollowUser({ + required String targetId, + required String authToken, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_UnfollowUser', + sha256Hash: _unfollowUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': {'targetID': targetId}, + }, + successPath: const ['unfollowUser'], + ); + } + + /// Mints a Client-Integrity token for authenticated GQL mutations. + /// + /// Bound to [deviceId] and [authToken]; send the same values on follow calls. + /// Twitch may still reject bot-flagged tokens — then the mutation fails with + /// [IntegrityCheckException]. + Future<({String token, int expirationMs})> fetchIntegrityToken({ + required String authToken, + required String deviceId, + }) async { + final response = await post( + '/integrity', + data: {}, + headers: { + 'Client-ID': twitchGqlClientId, + 'Authorization': 'OAuth $authToken', + 'X-Device-Id': deviceId, + 'User-Agent': playbackUserAgent, + 'Content-Type': 'application/json', + }, + ); + + final token = response['token'] as String?; + final expiration = response['expiration']; + if (token == null || token.isEmpty) { + throw const ApiException('Twitch integrity token missing from response'); + } + final expirationMs = expiration is int + ? expiration + : int.tryParse('$expiration') ?? 0; + return (token: token, expirationMs: expirationMs); + } + + Future _mutateFollow({ + required String operationName, + required String sha256Hash, + required String authToken, + required JsonMap variables, + required List successPath, + String? integrityToken, + String? deviceId, + }) async { + final headers = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + 'Authorization': 'OAuth $authToken', + 'User-Agent': playbackUserAgent, + if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, + if (integrityToken != null && integrityToken.isNotEmpty) + 'Client-Integrity': integrityToken, + }; + + final response = await post( + '/gql', + data: { + 'operationName': operationName, + 'variables': variables, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': sha256Hash, + }, + }, + }, + headers: headers, + ); + + final map = _gqlPayloadMap(response); + _throwIfGqlFailed(map); + + JsonMap? cursor = map['data'] as JsonMap?; + for (final key in successPath) { + final next = cursor?[key]; + if (next is JsonMap) { + cursor = next; + } else if (next == null) { + throw ApiException('Twitch $operationName returned no data'); + } else { + return; + } + } + } + + JsonMap _gqlPayloadMap(dynamic response) { + if (response is List) { + if (response.isEmpty || response.first is! Map) { + throw const ApiException('Unexpected Twitch GQL response'); + } + return Map.from(response.first as Map); + } + if (response is Map) { + return Map.from(response); + } + throw const ApiException('Unexpected Twitch GQL response'); + } + + void _throwIfGqlFailed(JsonMap map) { + final errors = map['errors']; + if (errors is! List || errors.isEmpty) return; + + final message = + (errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error'; + final lower = message.toLowerCase(); + if (lower.contains('integrity')) { + throw IntegrityCheckException(message); + } + throw ApiException(message); + } + StreamsTwitch _streamsFromConnection(JsonMap connection) { final edges = (connection['edges'] as List?) ?? const []; final pageInfo = connection['pageInfo'] as JsonMap?; diff --git a/lib/main.dart b/lib/main.dart index dd221dff..b0b87d00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,7 +167,10 @@ void main() async { ); // Create and initialize the authentication store - final authStore = AuthStore(twitchApi: twitchApiService); + final authStore = AuthStore( + twitchApi: twitchApiService, + twitchGqlApi: twitchGqlApiService, + ); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index f7d9f115..ab09ce96 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const LargeStreamCard({ super.key, @@ -28,6 +30,8 @@ class LargeStreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -95,6 +99,8 @@ class LargeStreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/offline_channel_card.dart b/lib/screens/home/stream_list/offline_channel_card.dart index 799a0c74..2fe17d46 100644 --- a/lib/screens/home/stream_list/offline_channel_card.dart +++ b/lib/screens/home/stream_list/offline_channel_card.dart @@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget { final bool showPinOption; final bool? isPinned; final bool showOfflineStatus; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const OfflineChannelCard({ super.key, @@ -22,6 +24,8 @@ class OfflineChannelCard extends StatelessWidget { required this.showPinOption, this.isPinned, this.showOfflineStatus = false, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -77,6 +81,8 @@ class OfflineChannelCard extends StatelessWidget { userId: channelInfo.broadcasterId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 6c7a10c5..0ffa914c 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const StreamCard({ super.key, @@ -34,6 +36,8 @@ class StreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -224,6 +228,8 @@ class StreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index e24e6812..46c1ffc1 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -260,6 +260,12 @@ class _StreamsListState extends State : _listStore.streams; final isFollowingTab = widget.listType == ListType.followed; + // Refresh following lists after unfollow so live/offline rows drop. + void onFollowChanged(bool following) { + if (isFollowingTab && !following) { + _listStore.refreshStreams(); + } + } return Stack( alignment: AlignmentDirectional.bottomCenter, @@ -320,6 +326,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ) : StreamCard( key: ValueKey(stream.userId), @@ -331,6 +340,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ); }, ); @@ -373,6 +385,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ) : StreamCard( key: ValueKey( @@ -384,6 +399,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ), ); }, @@ -432,6 +450,8 @@ class _StreamsListState extends State _listStore.offlineChannels[index], showPinOption: false, isPinned: false, + initialIsFollowing: true, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d0be4504..d4581732 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; @@ -41,6 +42,9 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. final TwitchApi twitchApi; + /// Web GQL client (follow/unfollow + playback tokens). + final TwitchGqlApi twitchGqlApi; + /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -259,9 +263,13 @@ abstract class AuthBase with Store { ); } - AuthBase({required this.twitchApi}) { + AuthBase({ + required this.twitchApi, + required this.twitchGqlApi, + }) { user = UserStore( twitchApi: twitchApi, + twitchGqlApi: twitchGqlApi, hasModeratorScopes: () => hasModeratorScopes, ); } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 60cd7dd7..058ed886 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -1,6 +1,11 @@ +import 'dart:math'; + +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/user.dart'; import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'user_store.g.dart'; @@ -8,6 +13,7 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; /// Whether the active auth token includes Frosty's moderator OAuth scopes. final bool Function() hasModeratorScopes; @@ -24,10 +30,20 @@ abstract class UserStoreBase with Store { @readonly var _moderatedChannels = ObservableList(); + /// Cached follow state for channels queried this session (id → following). + final _followCache = ObservableMap(); + + static const _deviceIdKey = 'twitch_gql_device_id'; + + String? _deviceId; + String? _integrityToken; + int _integrityExpirationMs = 0; + ReactionDisposer? _disposeReaction; UserStoreBase({ required this.twitchApi, + required this.twitchGqlApi, required this.hasModeratorScopes, }); @@ -61,6 +77,114 @@ abstract class UserStoreBase with Store { ); } + /// Whether we already know follow state for [channelId] this session. + bool? cachedIsFollowing(String channelId) => _followCache[channelId]; + + @action + Future isFollowingChannel(String channelId) async { + final cached = _followCache[channelId]; + if (cached != null) return cached; + + final userId = _details?.id; + if (userId == null) return false; + + final following = await twitchApi.isFollowingChannel( + userId: userId, + broadcasterId: channelId, + ); + _followCache[channelId] = following; + return following; + } + + /// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token. + @action + Future follow({ + required String targetId, + required String gqlToken, + }) async { + await _followMutation( + targetId: targetId, + gqlToken: gqlToken, + follow: true, + ); + _followCache[targetId] = true; + } + + /// Unfollows [targetId] via web GQL. + @action + Future unfollow({ + required String targetId, + required String gqlToken, + }) async { + await _followMutation( + targetId: targetId, + gqlToken: gqlToken, + follow: false, + ); + _followCache[targetId] = false; + } + + Future _followMutation({ + required String targetId, + required String gqlToken, + required bool follow, + }) async { + final deviceId = await _ensureDeviceId(); + + Future run({String? integrityToken}) { + if (follow) { + return twitchGqlApi.followUser( + targetId: targetId, + authToken: gqlToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + return twitchGqlApi.unfollowUser( + targetId: targetId, + authToken: gqlToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + + try { + await run(integrityToken: _validIntegrityToken()); + } on IntegrityCheckException { + final integrity = await twitchGqlApi.fetchIntegrityToken( + authToken: gqlToken, + deviceId: deviceId, + ); + _integrityToken = integrity.token; + _integrityExpirationMs = integrity.expirationMs; + await run(integrityToken: integrity.token); + } + } + + String? _validIntegrityToken() { + if (_integrityToken == null) return null; + if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { + return null; + } + return _integrityToken; + } + + Future _ensureDeviceId() async { + if (_deviceId != null) return _deviceId!; + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_deviceIdKey); + if (id == null || id.isEmpty) { + final random = Random.secure(); + id = List.generate( + 32, + (_) => random.nextInt(16).toRadixString(16), + ).join(); + await prefs.setString(_deviceIdKey, id); + } + _deviceId = id; + return id; + } + @action Future block({ required String targetId, @@ -161,6 +285,9 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); + _followCache.clear(); + _integrityToken = null; + _integrityExpirationMs = 0; _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index 084a0bda..e138aa77 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } + late final _$isFollowingChannelAsyncAction = AsyncAction( + 'UserStoreBase.isFollowingChannel', + context: context, + ); + + @override + Future isFollowingChannel(String channelId) { + return _$isFollowingChannelAsyncAction.run( + () => super.isFollowingChannel(channelId), + ); + } + + late final _$followAsyncAction = AsyncAction( + 'UserStoreBase.follow', + context: context, + ); + + @override + Future follow({required String targetId, required String gqlToken}) { + return _$followAsyncAction.run( + () => super.follow(targetId: targetId, gqlToken: gqlToken), + ); + } + + late final _$unfollowAsyncAction = AsyncAction( + 'UserStoreBase.unfollow', + context: context, + ); + + @override + Future unfollow({required String targetId, required String gqlToken}) { + return _$unfollowAsyncAction.run( + () => super.unfollow(targetId: targetId, gqlToken: gqlToken), + ); + } + late final _$blockAsyncAction = AsyncAction( 'UserStoreBase.block', context: context, diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index d86c75ae..f1d1c8fa 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; -class UserActionsModal extends StatelessWidget { +class UserActionsModal extends StatefulWidget { final AuthStore authStore; final String name; final String userLogin; @@ -14,6 +18,12 @@ class UserActionsModal extends StatelessWidget { final bool showPinOption; final bool? isPinned; + /// When set (e.g. Following tab), skip the Helix lookup and show this state. + final bool? initialIsFollowing; + + /// Called after a successful follow (`true`) or unfollow (`false`). + final ValueChanged? onFollowChanged; + /// Optional moderation actions (shown at the top) for chat contexts. Kept as /// an injected widget so this generic modal stays chat-agnostic. final Widget? moderationActions; @@ -26,51 +36,229 @@ class UserActionsModal extends StatelessWidget { required this.userId, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, this.moderationActions, }); + @override + State createState() => _UserActionsModalState(); +} + +class _UserActionsModalState extends State { + bool? _isFollowing; + bool _followLoading = false; + bool _followActionInProgress = false; + + bool get _isSelf => + widget.authStore.user.details?.id != null && + widget.authStore.user.details!.id == widget.userId; + + bool get _canFollow => + widget.authStore.isLoggedIn && !_isSelf; + + @override + void initState() { + super.initState(); + if (!_canFollow) return; + if (widget.initialIsFollowing != null) { + _isFollowing = widget.initialIsFollowing; + } else { + _loadFollowState(); + } + } + + Future _loadFollowState() async { + final cached = widget.authStore.user.cachedIsFollowing(widget.userId); + if (cached != null) { + setState(() => _isFollowing = cached); + return; + } + + setState(() => _followLoading = true); + try { + final following = await widget.authStore.user.isFollowingChannel( + widget.userId, + ); + if (mounted) { + setState(() { + _isFollowing = following; + _followLoading = false; + }); + } + } catch (_) { + if (mounted) { + setState(() => _followLoading = false); + } + } + } + + Future _toggleFollow() async { + if (_followActionInProgress || _isFollowing == null) return; + + final gqlToken = widget.authStore.gqlToken; + if (gqlToken == null) { + await _promptLinkWebSession(); + return; + } + + final following = _isFollowing!; + setState(() => _followActionInProgress = true); + + try { + if (following) { + await widget.authStore.user.unfollow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } else { + await widget.authStore.user.follow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } + + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); + widget.onFollowChanged?.call(nowFollowing); + + final messenger = ScaffoldMessenger.maybeOf(context); + Navigator.pop(context); + messenger?.showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), + ), + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: e.message, + centered: false, + ), + ), + ); + } catch (_) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: following + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + centered: false, + ), + ), + ); + } + } + + Future _promptLinkWebSession() { + return showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Web session required', + message: + 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LoginWebView()), + ); + }, + child: const Text('Log in'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { + final showFollowTile = _canFollow && (_followLoading || _isFollowing != null); + return ListView( primary: false, shrinkWrap: true, children: [ - ?moderationActions, - if (showPinOption) + ?widget.moderationActions, + if (widget.showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), - title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'), + title: Text( + '${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}', + ), onTap: () { - if (isPinned == true) { + if (widget.isPinned == true) { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds - ..remove(userId), + ..remove(widget.userId), ]; } else { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds, - userId, + widget.userId, ]; } Navigator.pop(context); }, ), - if (authStore.isLoggedIn) + if (showFollowTile) + ListTile( + leading: _followLoading || _followActionInProgress + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _isFollowing == true + ? Icons.heart_broken_rounded + : Icons.favorite_rounded, + ), + title: Text( + _isFollowing == true + ? 'Unfollow ${widget.name}' + : 'Follow ${widget.name}', + ), + enabled: !_followLoading && !_followActionInProgress, + onTap: _toggleFollow, + ), + if (widget.authStore.isLoggedIn) ListTile( leading: const Icon(Icons.block_rounded), - onTap: () => authStore + onTap: () => widget.authStore .showBlockDialog( context, - targetUser: name, - targetUserId: userId, + targetUser: widget.name, + targetUserId: widget.userId, ) .then((_) { if (context.mounted) { Navigator.pop(context); } }), - title: Text('Block $name'), + title: Text('Block ${widget.name}'), ), ListTile( leading: const Icon(Icons.outlined_flag_rounded), @@ -101,7 +289,7 @@ class UserActionsModal extends StatelessWidget { icon: Icon(Icons.adaptive.arrow_back_rounded), onPressed: Navigator.of(context).pop, ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), body: Stack( children: [ @@ -118,7 +306,7 @@ class UserActionsModal extends StatelessWidget { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest( Uri.parse( - 'https://www.twitch.tv/$userLogin/report', + 'https://www.twitch.tv/${widget.userLogin}/report', ), ) ..setNavigationDelegate( @@ -154,7 +342,7 @@ class UserActionsModal extends StatelessWidget { }, ), ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), ], ); diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 7727e23f..4180a464 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -587,4 +587,55 @@ void main() { ); }); }); + + group('isFollowingChannel', () { + test('returns true when Helix lists the broadcaster', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 1, + 'data': [ + { + 'broadcaster_id': '999', + 'broadcaster_login': 'streamer', + 'broadcaster_name': 'Streamer', + 'followed_at': '2024-01-01T00:00:00Z', + }, + ], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isTrue, + ); + }); + + test('returns false when not following', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 0, + 'data': [], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isFalse, + ); + }); + }); } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart new file mode 100644 index 00000000..0e4bb171 --- /dev/null +++ b/test/apis/twitch_gql_follow_test.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchGqlApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchGqlApi(dio); + }); + + group('followUser', () { + test('posts FollowButton_FollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'followUser': { + 'follow': { + 'user': {'id': '999', 'displayName': 'Streamer'}, + }, + }, + }, + }), + data: Matchers.any, + ); + + await api.followUser(targetId: '999', authToken: 'web-token'); + }); + + test('throws IntegrityCheckException on integrity errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'failed integrity check'}, + ], + }), + data: Matchers.any, + ); + + await expectLater( + api.followUser(targetId: '999', authToken: 'web-token'), + throwsA(isA()), + ); + }); + }); + + group('unfollowUser', () { + test('posts FollowButton_UnfollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'unfollowUser': { + 'follow': null, + }, + }, + }), + data: Matchers.any, + ); + + await api.unfollowUser(targetId: '999', authToken: 'web-token'); + }); + }); +} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 4affd3d7..9d11c922 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,16 +1,24 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MockTwitchApi extends Mock implements TwitchApi {} +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + void main() { late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; setUp(() { + SharedPreferences.setMockInitialValues({}); api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); when(() => api.getUserInfo()).thenAnswer( (_) async => const UserTwitch( '12345', @@ -27,42 +35,39 @@ void main() { ); }); + UserStore makeStore({required bool hasModeratorScopes}) => UserStore( + twitchApi: api, + twitchGqlApi: gqlApi, + hasModeratorScopes: () => hasModeratorScopes, + ); + group('UserStore moderated channels gating', () { test('init skips moderated-channels without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); + final userStore = makeStore(hasModeratorScopes: false); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - expect(store.moderatedChannels, isEmpty); + expect(userStore.moderatedChannels, isEmpty); }); test('init fetches moderated-channels when moderator scopes exist', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => true, - ); + final userStore = makeStore(hasModeratorScopes: true); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verify(() => api.getModeratedChannels(id: '12345')).called(1); - expect(store.moderatedChannels, ['999']); + expect(userStore.moderatedChannels, ['999']); }); test('refreshModeratedChannels no-ops without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); clearInteractions(api); - await store.refreshModeratedChannels(); + await userStore.refreshModeratedChannels(); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); }); @@ -75,13 +80,10 @@ void main() { ), ).thenAnswer((_) async => true); - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); - final result = await store.deleteMessage( + final result = await userStore.deleteMessage( broadcasterId: '12345', messageId: 'msg', ); @@ -96,4 +98,85 @@ void main() { ); }); }); + + group('UserStore follow', () { + test('isFollowingChannel caches Helix result', () async { + when( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).thenAnswer((_) async => true); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + + expect(await userStore.isFollowingChannel('999'), isTrue); + expect(await userStore.isFollowingChannel('999'), isTrue); + verify( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).called(1); + }); + + test('follow retries after integrity failure', () async { + var calls = 0; + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async { + calls++; + if (calls == 1) { + throw const IntegrityCheckException('failed integrity check'); + } + }); + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer( + (_) async => ( + token: 'integrity', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.follow(targetId: '999', gqlToken: 'web-token'); + + expect(userStore.cachedIsFollowing('999'), isTrue); + expect(calls, 2); + verify( + () => gqlApi.fetchIntegrityToken( + authToken: 'web-token', + deviceId: any(named: 'deviceId'), + ), + ).called(1); + }); + + test('unfollow updates cache', () async { + when( + () => gqlApi.unfollowUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.unfollow(targetId: '999', gqlToken: 'web-token'); + + expect(userStore.cachedIsFollowing('999'), isFalse); + }); + }); } From 383e78647957e8a46bd2a729d8d4376bfabb53b8 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 03:06:09 +0200 Subject: [PATCH 61/67] release v5.2.8 Co-authored-by: Cursor --- assets/release-notes.md | 90 +++++------------------------------------ pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 81 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 28d3b3e9..d25f1cef 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.2.8 + +October 7, 2026 + +### New Features + +- Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session). + +--- + ## v5.2.7 October 1, 2026 @@ -15,83 +25,3 @@ October 1, 2026 ### Bug Fixes - Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally. - ---- - -## v5.2.1 - -September 30, 2026 - -### New Features - -- Added TwitchAdSolutions-style ad blocking for the native player (and best-effort WebView inject), with a Settings toggle. -- Added Forgejo Obtainium release builds for this fork. - ---- - -## v5.2.0 - -August 16, 2026 - -### New Features - -- Added moderator actions to delete messages, timeout, ban, and unban users from the message long-press menu and user panel (by @francislavoie on GitHub). -- Added channel bubbles in Twitch shared chat, so you can focus one participant or hide their messages. - -### Improvements - -- Improved the default video player by switching to native. It handles Picture-in-Picture, quality selection, and latency better than the legacy WebView player. -- Improved native player stability across device rotation (by @francislavoie on GitHub). -- [Android] Improved native player latency for live streams (by @francislavoie on GitHub). - -### Bug Fixes - -- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream. -- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub). - ---- - -## v5.1.0 - -June 13, 2026 - -### New Features - -- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery. -- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together. -- Added new chat settings for keeping the screen on and showing timestamps on historical messages. -- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels. - -### Improvements - -- Improved chat send reliability, especially when chat delay is enabled. -- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages. -- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks. -- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options. - -### Bug Fixes - -- Fixed recent messages failing on valueless or escaped IRC tags. -- Fixed shared chat badges and emotes lingering after leaving shared chat. -- Fixed recent searches not saving from type-to-search. -- Fixed auto-synced chat delay not applying when toggled on mid-session. -- Fixed long chat inputs overlapping the message list. -- Fixed a video refresh race that could leave a black screen with only the play button visible. -- Fixed fullscreen button and system UI state getting out of sync. -- Fixed landscape safe area and iPad gesture padding issues. -- Fixed Twitch/API auth requests that could hang instead of failing cleanly. - ---- - -## v5.0.5 - -January 9, 2026 - -### Bug Fixes - -- Fixed lag/frame drops in overlay chat mode. -- Fixed draggable divider still interactable in fullscreen mode. -- Fixed landscape bottom padding in chat bottom bar. -- [iOS] Fixed user agent breaking login. - -#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases) diff --git a/pubspec.yaml b/pubspec.yaml index 8e1a5656..559d5f51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.7+77 +version: 5.2.8+78 environment: sdk: ^3.9.0 From 3fb393166fbe4df4b9f7ce95bd08c4650cb9a8ac Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 07:32:16 +0200 Subject: [PATCH 62/67] Fix tag directory sorting and follow Client-Integrity. Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor --- DESIGN.md | 7 +- lib/apis/twitch_api.dart | 4 +- lib/apis/twitch_gql_api.dart | 139 +++++++---- lib/apis/twitch_integrity.dart | 81 ++++++ .../home/stream_list/stream_list_store.dart | 224 +++++++++-------- lib/screens/settings/stores/user_store.dart | 106 ++++++-- lib/widgets/integrity_webview_dialog.dart | 232 ++++++++++++++++++ lib/widgets/user_actions_modal.dart | 121 ++++++--- test/apis/twitch_gql_follow_test.dart | 30 +++ test/apis/twitch_gql_streams_test.dart | 129 ++++++++++ test/apis/twitch_integrity_test.dart | 42 ++++ .../stream_list/stream_list_store_test.dart | 203 +++++++++++++++ .../settings/stores/user_store_test.dart | 86 +++++++ 13 files changed, 1197 insertions(+), 207 deletions(-) create mode 100644 lib/apis/twitch_integrity.dart create mode 100644 lib/widgets/integrity_webview_dialog.dart create mode 100644 test/apis/twitch_gql_streams_test.dart create mode 100644 test/apis/twitch_integrity_test.dart create mode 100644 test/screens/home/stream_list/stream_list_store_test.dart diff --git a/DESIGN.md b/DESIGN.md index 3ad3817c..07a0fe02 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,9 +51,10 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter - Viewer sort is persisted (`streamViewerSort`) -- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match) -- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails) -- Tag directories never rely on GQL cursors for load-more +- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session. +- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`. +- **Top / category most viewers (desc):** Helix directory order (paginates) +- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback ## Build diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index ca9f49f8..f3cc787d 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -271,8 +271,8 @@ class TwitchApi extends BaseApiClient { /// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// /// Helix is always viewer-desc and supports at most one `language` query - /// param; tags are filtered client-side. Prefer this over GQL for pagination - /// (GQL page-2 requires a non-bot Client-Integrity token). + /// param; tags are filtered client-side. Used as fallback when GQL directory + /// pagination lacks a Client-Integrity token. /// /// When a [freeformTag] filter yields no matches in a Helix window, this /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 0fce183a..3923d500 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; @@ -31,27 +32,49 @@ class TwitchGqlApi extends BaseApiClient { 'Content-Type': 'application/json', }; - Future _gql(Map body) async { + Future _gql( + Map body, { + Map? extraHeaders, + }) async { final response = await post( '/gql', data: body, - headers: _gqlHeaders, + headers: {..._gqlHeaders, ...?extraHeaders}, ); final errors = response['errors']; if (errors is List && errors.isNotEmpty) { + final message = (errors.first as JsonMap)['message'] as String? ?? + 'Twitch GQL error'; + if (message.toLowerCase().contains('integrity')) { + throw IntegrityCheckException(message); + } final data = response['data']; final dataEmpty = data == null || (data is Map && data.values.every((value) => value == null)); if (dataEmpty) { - final message = (errors.first as JsonMap)['message'] as String? ?? - 'Twitch GQL error'; throw ApiException(message); } } return response; } + /// Optional web-session headers for directory pagination (page-2+). + Map _directoryHeaders({ + String? authToken, + String? integrityToken, + String? deviceId, + }) { + return { + 'User-Agent': playbackUserAgent, + if (authToken != null && authToken.isNotEmpty) + 'Authorization': 'OAuth $authToken', + if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, + if (integrityToken != null && integrityToken.isNotEmpty) + 'Client-Integrity': integrityToken, + }; + } + /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -213,15 +236,18 @@ class TwitchGqlApi extends BaseApiClient { /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// - /// Twitch caps `first` at 30 for this connection. Cursor pagination often - /// fails with `IntegrityCheckFailed` without a web integrity token — callers - /// should treat a failed page-2 as end-of-list. + /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a + /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without + /// them page-2 throws [IntegrityCheckException]. Future getStreams({ List? freeformTags, List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, + String? authToken, + String? integrityToken, + String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -231,24 +257,31 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql({ - 'operationName': 'FrostyTopStreams', - 'query': - r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' - r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } }', - 'variables': { - 'first': cappedFirst, - 'after': cursor, - 'tags': freeformTags, - 'sort': sort.gqlValue, - 'langs': gqlLangs, + final response = await _gql( + { + 'operationName': 'FrostyTopStreams', + 'query': + r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' + r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } }', + 'variables': { + 'first': cappedFirst, + 'after': cursor, + 'tags': freeformTags, + 'sort': sort.gqlValue, + 'langs': gqlLangs, + }, }, - }); + extraHeaders: _directoryHeaders( + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ), + ); final connection = (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; @@ -262,6 +295,9 @@ class TwitchGqlApi extends BaseApiClient { StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, + String? authToken, + String? integrityToken, + String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -271,24 +307,31 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql({ - 'operationName': 'FrostyGameStreams', - 'query': - r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' - r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } } }', - 'variables': { - 'id': gameId, - 'first': cappedFirst, - 'after': cursor, - 'sort': sort.gqlValue, - 'langs': gqlLangs, + final response = await _gql( + { + 'operationName': 'FrostyGameStreams', + 'query': + r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' + r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } } }', + 'variables': { + 'id': gameId, + 'first': cappedFirst, + 'after': cursor, + 'sort': sort.gqlValue, + 'langs': gqlLangs, + }, }, - }); + extraHeaders: _directoryHeaders( + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ), + ); final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; if (game == null) { @@ -354,8 +397,9 @@ class TwitchGqlApi extends BaseApiClient { /// Mints a Client-Integrity token for authenticated GQL mutations. /// /// Bound to [deviceId] and [authToken]; send the same values on follow calls. - /// Twitch may still reject bot-flagged tokens — then the mutation fails with - /// [IntegrityCheckException]. + /// Tokens minted outside a real browser are usually flagged `is_bad_bot` — + /// those throw [IntegrityCheckException] so callers can fall back to a + /// WebView capture. Future<({String token, int expirationMs})> fetchIntegrityToken({ required String authToken, required String deviceId, @@ -377,9 +421,16 @@ class TwitchGqlApi extends BaseApiClient { if (token == null || token.isEmpty) { throw const ApiException('Twitch integrity token missing from response'); } + if (TwitchIntegrity.isBadBot(token)) { + throw const IntegrityCheckException( + 'Twitch integrity token flagged as bad bot', + ); + } final expirationMs = expiration is int ? expiration - : int.tryParse('$expiration') ?? 0; + : int.tryParse('$expiration') ?? + TwitchIntegrity.expirationMs(token) ?? + 0; return (token: token, expirationMs: expirationMs); } diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart new file mode 100644 index 00000000..efef0346 --- /dev/null +++ b/lib/apis/twitch_integrity.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`). +class TwitchIntegrity { + const TwitchIntegrity._(); + + /// Decodes the JSON payload embedded in an integrity token, if present. + static Map? decodePayload(String token) { + final parts = token.split('.'); + if (parts.length < 2) return null; + + for (final part in parts.skip(1)) { + if (part.isEmpty) continue; + try { + final normalized = base64Url.normalize(part); + final decoded = utf8.decode(base64Url.decode(normalized)); + final dynamic json = jsonDecode(decoded); + if (json is Map && + (json.containsKey('is_bad_bot') || + json.containsKey('client_id') || + json.containsKey('device_id'))) { + return Map.from(json); + } + } catch (_) { + // Try the next segment — token layout varies by version. + } + } + return null; + } + + /// Dio/`POST /integrity` tokens from non-browser clients are usually flagged. + static bool isBadBot(String token) { + final payload = decodePayload(token); + if (payload == null) return false; + final flag = payload['is_bad_bot']; + if (flag is bool) return flag; + if (flag is String) return flag.toLowerCase() == 'true'; + return false; + } + + /// Expiration in epoch millis from the payload, if present. + static int? expirationMs(String token) { + final payload = decodePayload(token); + if (payload == null) return null; + + final exp = payload['exp']; + if (exp is int) { + // Already millis if large; seconds if typical JWT. + return exp > 100000000000 ? exp : exp * 1000; + } + if (exp is String) { + final asInt = int.tryParse(exp); + if (asInt != null) { + return asInt > 100000000000 ? asInt : asInt * 1000; + } + final dt = DateTime.tryParse(exp); + if (dt != null) return dt.millisecondsSinceEpoch; + } + return null; + } + + /// Device id the token was minted for (`X-Device-Id` must match). + static String? deviceId(String token) { + final payload = decodePayload(token); + final id = payload?['device_id']; + return id is String && id.isNotEmpty ? id : null; + } +} + +/// Credentials captured from a browser/WebView GQL request. +class GqlIntegrityCredentials { + final String integrityToken; + final String deviceId; + final int expirationMs; + + const GqlIntegrityCredentials({ + required this.integrityToken, + required this.deviceId, + required this.expirationMs, + }); +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index f8e97b69..f7165dd4 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -51,9 +51,13 @@ abstract class ListStoreBase with Store { /// The pagination cursor for the streams. String? _streamsCursor; - /// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity - /// fails). Helix starts from the top; duplicates are skipped. - var _ascHelixFill = false; + /// When true, pages come from Helix (GQL cursor/integrity unavailable). + /// ASC Helix fill starts from the top; duplicates are skipped and the list + /// is re-sorted client-side. + var _helixFill = false; + + /// Last page was served by GQL with server-side sort — append in API order. + var _lastPageWasGql = false; /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -64,7 +68,7 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed bool get hasMore => - isLoading == false && (_streamsCursor != null || _ascHelixFill); + isLoading == false && (_streamsCursor != null || _helixFill); @computed bool get isLoading => @@ -255,6 +259,14 @@ abstract class ListStoreBase with Store { } } + /// Prefer GQL for tag directories (both sorts) and for ascending Top/category. + bool get _preferGqlDirectory { + if (twitchGqlApi == null || _helixFill) return false; + if (listType == ListType.tag) return true; + return listType != ListType.followed && + sortOrder == StreamViewerSort.viewersAsc; + } + /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { @@ -265,84 +277,41 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; final isAsc = sortOrder == StreamViewerSort.viewersAsc; - // GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed. - // First empty list → GQL ASC once; further pages → Helix fill + dedupe. - final gqlAscFirstPage = isAsc && - twitchGqlApi != null && - !_ascHelixFill && - _allStreams.isEmpty && - listType != ListType.followed; + final useGql = _preferGqlDirectory; + _lastPageWasGql = false; - final StreamsTwitch newStreams; - switch (listType) { - case ListType.followed: - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - break; - case ListType.top: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getStreams( - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - cursor: _streamsCursor, - minCount: isAsc ? 30 : 50, - maxPages: isAsc ? 20 : 12, - ); - } - break; - case ListType.category: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getGameStreams( - gameId: categoryId!, - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - minCount: isAsc ? 30 : 50, - maxPages: isAsc ? 20 : 12, - ); - } - break; - case ListType.tag: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getStreams( - freeformTags: [freeformTag!], - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - // Desc, and ASC after GQL: Helix + tag filter (paginates). - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - freeformTag: freeformTag, - cursor: _streamsCursor, - minCount: 40, - maxPages: 25, - ); - } - break; + StreamsTwitch? newStreams; + if (listType == ListType.followed) { + newStreams = await twitchApi.getFollowedStreams( + id: authStore.user.details!.id, + cursor: _streamsCursor, + ); + } else if (useGql) { + newStreams = await _fetchGqlDirectory(languages: languages); + if (newStreams != null) { + _lastPageWasGql = true; + } } + // Helix: Top/category DESC, or GQL unavailable / integrity fallback. + newStreams ??= await twitchApi.getDirectoryStreams( + languages: languages, + gameId: listType == ListType.category ? categoryId : null, + freeformTag: listType == ListType.tag ? freeformTag : null, + cursor: _streamsCursor, + minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50), + maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12), + ); + final next = newStreams.pagination['cursor']; - final hasHelixCursor = next != null && next.isNotEmpty; + final hasCursor = next != null && next.isNotEmpty; // Empty Helix window with cursor left ⇒ keep scanning (sparse tags). - final isHelixContinuation = - _streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty); - if (isHelixContinuation && - newStreams.data.isEmpty && - !gqlAscFirstPage) { - if (hasHelixCursor) { + final isHelixContinuation = !_lastPageWasGql && + (_streamsCursor != null || + (_helixFill && _allStreams.isNotEmpty)); + if (isHelixContinuation && newStreams.data.isEmpty) { + if (hasCursor) { _streamsCursor = next; _error = null; _isAllStreamsLoading = false; @@ -350,20 +319,20 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; return; } - // Keep API order for desc. ASC: sanitize page, then full-list sort after - // Helix fill so newly found streams land in fewest-first order. - final pageData = isAsc + // GQL pages are already server-sorted — keep API order. + // Helix ASC fill: sort page + full list so late matches land correctly. + final pageData = (!_lastPageWasGql && isAsc) ? (List.of(newStreams.data)..sort( (a, b) => a.viewerCount.compareTo(b.viewerCount), )) : newStreams.data; - if (_allStreams.isEmpty && !_ascHelixFill) { + if (_allStreams.isEmpty && !_helixFill) { _allStreams = pageData.asObservable(); } else { final existingIds = _allStreams.map((s) => s.userId).toSet(); @@ -372,23 +341,17 @@ abstract class ListStoreBase with Store { ); } - if (isAsc && _allStreams.length > 1) { + if (!_lastPageWasGql && isAsc && _allStreams.length > 1) { final sorted = _allStreams.toList() ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); _allStreams = sorted.asObservable(); } - if (gqlAscFirstPage) { - // Ignore GQL cursor — page-2 integrity fails. Fill via Helix next. - _ascHelixFill = true; - _streamsCursor = null; - } else { - _streamsCursor = hasHelixCursor ? next : null; - if (_streamsCursor == null) { - _ascHelixFill = false; - } else if (isAsc) { - _ascHelixFill = true; - } + _streamsCursor = hasCursor ? next : null; + if (_streamsCursor == null) { + _helixFill = false; + } else if (!_lastPageWasGql && isAsc) { + _helixFill = true; } _error = null; @@ -397,15 +360,16 @@ abstract class ListStoreBase with Store { debugPrint('Streams SocketException: No internet connection'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); if (_allStreams.isNotEmpty) { - // GQL page-2 integrity or mid-scroll error: fall back to Helix fill. - if (isAscDirectory && !_ascHelixFill) { - _ascHelixFill = true; + // Mid-scroll GQL failure: fall back to Helix fill when useful. + if (!_helixFill && + (listType == ListType.tag || isAscDirectory)) { + _helixFill = true; _streamsCursor = null; _error = null; _isAllStreamsLoading = false; @@ -413,7 +377,7 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; } } catch (e) { @@ -421,7 +385,7 @@ abstract class ListStoreBase with Store { debugPrint('Streams error: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; } } finally { @@ -429,6 +393,63 @@ abstract class ListStoreBase with Store { } } + /// GQL directory page with integrity retry. Returns null to trigger Helix. + Future _fetchGqlDirectory({ + required List languages, + }) async { + final gql = twitchGqlApi; + if (gql == null) return null; + + final langs = languages.isEmpty ? null : languages; + + try { + return await authStore.user.runWithGqlIntegrity( + gqlToken: authStore.gqlToken, + action: ({authToken, integrityToken, deviceId}) { + switch (listType) { + case ListType.tag: + return gql.getStreams( + freeformTags: [freeformTag!], + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.category: + return gql.getGameStreams( + gameId: categoryId!, + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.top: + return gql.getStreams( + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.followed: + throw StateError('GQL directory not used for followed'); + } + }, + ); + } on IntegrityCheckException catch (e) { + debugPrint('Streams GQL integrity: $e — falling back to Helix'); + _helixFill = true; + // Keep cursor null so Helix starts from the top (dedupe handles overlap). + _streamsCursor = null; + return null; + } + } + bool get isAscDirectory => sortOrder == StreamViewerSort.viewersAsc && listType != ListType.followed; @@ -514,7 +535,8 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; + _lastPageWasGql = false; await getStreams(); } @@ -560,5 +582,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page). +/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). enum ListType { followed, top, category, tag } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 058ed886..de29e1da 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -129,40 +130,98 @@ abstract class UserStoreBase with Store { required String gqlToken, required bool follow, }) async { - final deviceId = await _ensureDeviceId(); - - Future run({String? integrityToken}) { - if (follow) { - return twitchGqlApi.followUser( + await runWithGqlIntegrity( + gqlToken: gqlToken, + action: ({authToken, integrityToken, deviceId}) { + if (follow) { + return twitchGqlApi.followUser( + targetId: targetId, + authToken: authToken!, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + return twitchGqlApi.unfollowUser( targetId: targetId, - authToken: gqlToken, + authToken: authToken!, integrityToken: integrityToken, deviceId: deviceId, ); - } - return twitchGqlApi.unfollowUser( - targetId: targetId, - authToken: gqlToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - } + }, + ); + } + + /// Runs a GQL call with optional Client-Integrity; refreshes once on + /// [IntegrityCheckException] when a web [gqlToken] is available. + /// + /// Dio-minted tokens that are `is_bad_bot` are discarded; callers should + /// then capture credentials via a WebView and [applyWebIntegrity]. + Future runWithGqlIntegrity({ + required String? gqlToken, + required Future Function({ + required String? authToken, + required String? integrityToken, + required String? deviceId, + }) action, + }) async { + final deviceId = await _ensureDeviceId(); + final authToken = + gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null; + + Future run(String? integrityToken) => action( + authToken: authToken, + integrityToken: integrityToken, + deviceId: _deviceId ?? deviceId, + ); try { - await run(integrityToken: _validIntegrityToken()); + return await run(_validIntegrityToken()); } on IntegrityCheckException { - final integrity = await twitchGqlApi.fetchIntegrityToken( - authToken: gqlToken, - deviceId: deviceId, - ); - _integrityToken = integrity.token; - _integrityExpirationMs = integrity.expirationMs; - await run(integrityToken: integrity.token); + if (authToken == null) { + clearIntegrity(); + rethrow; + } + try { + final integrity = await twitchGqlApi.fetchIntegrityToken( + authToken: authToken, + deviceId: _deviceId ?? deviceId, + ); + _integrityToken = integrity.token; + _integrityExpirationMs = integrity.expirationMs; + return await run(integrity.token); + } on IntegrityCheckException { + clearIntegrity(); + rethrow; + } } } + /// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]). + Future applyWebIntegrity(GqlIntegrityCredentials credentials) async { + if (TwitchIntegrity.isBadBot(credentials.integrityToken)) { + clearIntegrity(); + throw const IntegrityCheckException( + 'Twitch integrity token flagged as bad bot', + ); + } + _integrityToken = credentials.integrityToken; + _integrityExpirationMs = credentials.expirationMs; + _deviceId = credentials.deviceId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_deviceIdKey, credentials.deviceId); + } + + void clearIntegrity() { + _integrityToken = null; + _integrityExpirationMs = 0; + } + String? _validIntegrityToken() { if (_integrityToken == null) return null; + if (TwitchIntegrity.isBadBot(_integrityToken!)) { + clearIntegrity(); + return null; + } if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { return null; } @@ -286,8 +345,7 @@ abstract class UserStoreBase with Store { _blockedUsers.clear(); _moderatedChannels.clear(); _followCache.clear(); - _integrityToken = null; - _integrityExpirationMs = 0; + clearIntegrity(); _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/widgets/integrity_webview_dialog.dart b/lib/widgets/integrity_webview_dialog.dart new file mode 100644 index 00000000..9d9d9054 --- /dev/null +++ b/lib/widgets/integrity_webview_dialog.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a +/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`). +/// +/// Returns credentials, or null if the user cancels / capture times out. +Future showIntegrityWebViewDialog({ + required BuildContext context, + required String gqlToken, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken), + ); +} + +class IntegrityWebViewDialog extends StatefulWidget { + final String gqlToken; + + const IntegrityWebViewDialog({super.key, required this.gqlToken}); + + @override + State createState() => _IntegrityWebViewDialogState(); +} + +class _IntegrityWebViewDialogState extends State { + static const _captureTimeout = Duration(seconds: 45); + + late final WebViewController _controller; + Timer? _timeout; + var _captured = false; + var _status = 'Verifying with Twitch…'; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent( + Platform.isIOS + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 ' + 'Mobile/15E148 Safari/604.1' + : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', + ) + ..addJavaScriptChannel( + 'FrostyIntegrity', + onMessageReceived: _onIntegrityMessage, + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) => _injectHooks(), + onPageFinished: (_) => _injectHooks(), + ), + ); + + _prepareAndLoad(); + _timeout = Timer(_captureTimeout, () { + if (!mounted || _captured) return; + setState(() => _status = 'Timed out waiting for Twitch. Try again.'); + }); + } + + Future _prepareAndLoad() async { + try { + final cookies = WebViewCookieManager(); + await cookies.setCookie( + WebViewCookie( + name: 'auth-token', + value: widget.gqlToken, + domain: '.twitch.tv', + ), + ); + } catch (e) { + debugPrint('IntegrityWebView: cookie set failed: $e'); + } + + // Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL + // requests that carry a browser-valid Client-Integrity header. + await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login')); + } + + Future _injectHooks() async { + // Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic. + const script = r''' +(function () { + if (window.__frostyIntegrityHooked) return; + window.__frostyIntegrityHooked = true; + + function report(headers) { + if (!headers || !window.FrostyIntegrity) return; + var integrity = headers['Client-Integrity'] || headers['client-integrity']; + if (!integrity) return; + var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || ''; + var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || ''; + window.FrostyIntegrity.postMessage(JSON.stringify({ + integrity: integrity, + deviceId: deviceId, + clientId: clientId + })); + } + + function headersToObject(headers) { + var out = {}; + if (!headers) return out; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + headers.forEach(function (value, key) { out[key] = value; }); + return out; + } + if (typeof headers === 'object') { + Object.keys(headers).forEach(function (key) { out[key] = headers[key]; }); + } + return out; + } + + var originalFetch = window.fetch; + window.fetch = function (input, init) { + try { + report(headersToObject(init && init.headers)); + } catch (e) {} + return originalFetch.apply(this, arguments); + }; + + var originalOpen = XMLHttpRequest.prototype.open; + var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader; + var originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function () { + this.__frostyHeaders = {}; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.setRequestHeader = function (key, value) { + try { this.__frostyHeaders[key] = value; } catch (e) {} + return originalSetHeader.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + try { report(this.__frostyHeaders || {}); } catch (e) {} + return originalSend.apply(this, arguments); + }; +})(); +'''; + + try { + await _controller.runJavaScript(script); + } catch (e) { + debugPrint('IntegrityWebView: hook inject failed: $e'); + } + } + + void _onIntegrityMessage(JavaScriptMessage message) { + if (_captured || !mounted) return; + + try { + final map = jsonDecode(message.message) as Map; + final integrity = map['integrity'] as String?; + if (integrity == null || integrity.isEmpty) return; + if (TwitchIntegrity.isBadBot(integrity)) { + debugPrint('IntegrityWebView: ignoring bad_bot token'); + return; + } + + final fromHeader = map['deviceId'] as String?; + final fromToken = TwitchIntegrity.deviceId(integrity); + final deviceId = (fromHeader != null && fromHeader.isNotEmpty) + ? fromHeader + : (fromToken ?? ''); + if (deviceId.isEmpty) { + debugPrint('IntegrityWebView: missing device id'); + return; + } + + final expiration = TwitchIntegrity.expirationMs(integrity) ?? + DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds; + + _captured = true; + _timeout?.cancel(); + Navigator.of(context).pop( + GqlIntegrityCredentials( + integrityToken: integrity, + deviceId: deviceId, + expirationMs: expiration, + ), + ); + } catch (e) { + debugPrint('IntegrityWebView: parse failed: $e'); + } + } + + @override + void dispose() { + _timeout?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.sizeOf(context).height * 0.55; + return AlertDialog( + title: const Text('Confirm with Twitch'), + content: SizedBox( + width: double.maxFinite, + height: height.clamp(280.0, 520.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_status), + const SizedBox(height: 12), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: WebViewWidget(controller: _controller), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } +} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index f1d1c8fa..fad22c21 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -7,6 +7,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:frosty/widgets/integrity_webview_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -106,50 +107,64 @@ class _UserActionsModalState extends State { setState(() => _followActionInProgress = true); try { - if (following) { - await widget.authStore.user.unfollow( - targetId: widget.userId, - gqlToken: gqlToken, - ); - } else { - await widget.authStore.user.follow( - targetId: widget.userId, - gqlToken: gqlToken, + await _runFollowMutation(following: following, gqlToken: gqlToken); + } on IntegrityCheckException { + if (!mounted) return; + // Dio /integrity is almost always bad_bot — mint via WebView like Xtra. + final credentials = await showIntegrityWebViewDialog( + context: context, + gqlToken: gqlToken, + ); + if (!mounted) return; + if (credentials == null) { + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage( + message: 'Follow cancelled — Twitch verification required', + centered: false, + ), + ), ); + return; } - if (!mounted) return; - - final nowFollowing = !following; - setState(() { - _isFollowing = nowFollowing; - _followActionInProgress = false; - }); - widget.onFollowChanged?.call(nowFollowing); - - final messenger = ScaffoldMessenger.maybeOf(context); - Navigator.pop(context); - messenger?.showSnackBar( - SnackBar( - content: AlertMessage( - message: nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - centered: false, + try { + await widget.authStore.user.applyWebIntegrity(credentials); + await _runFollowMutation(following: following, gqlToken: gqlToken); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage(message: e.message, centered: false), ), - ), - ); + ); + return; + } catch (_) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: following + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + centered: false, + ), + ), + ); + return; + } } on ApiException catch (e) { if (!mounted) return; setState(() => _followActionInProgress = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: AlertMessage( - message: e.message, - centered: false, - ), + content: AlertMessage(message: e.message, centered: false), ), ); + return; } catch (_) { if (!mounted) return; setState(() => _followActionInProgress = false); @@ -163,6 +178,46 @@ class _UserActionsModalState extends State { ), ), ); + return; + } + + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); + widget.onFollowChanged?.call(nowFollowing); + + final messenger = ScaffoldMessenger.maybeOf(context); + Navigator.pop(context); + messenger?.showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), + ), + ); + } + + Future _runFollowMutation({ + required bool following, + required String gqlToken, + }) async { + if (following) { + await widget.authStore.user.unfollow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } else { + await widget.authStore.user.follow( + targetId: widget.userId, + gqlToken: gqlToken, + ); } } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart index 0e4bb171..00efa120 100644 --- a/test/apis/twitch_gql_follow_test.dart +++ b/test/apis/twitch_gql_follow_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; @@ -69,4 +71,32 @@ void main() { await api.unfollowUser(targetId: '999', authToken: 'web-token'); }); }); + + group('fetchIntegrityToken', () { + test('rejects bad_bot tokens', () async { + final payload = base64Url.encode( + utf8.encode( + jsonEncode({ + 'is_bad_bot': 'true', + 'device_id': 'dev', + 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', + }), + ), + ); + + dioAdapter.onPost( + 'https://gql.twitch.tv/integrity', + (server) => server.reply(200, { + 'token': 'v4.public.$payload.sig', + 'expiration': DateTime.now().millisecondsSinceEpoch + 600000, + }), + data: Matchers.any, + ); + + await expectLater( + api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'), + throwsA(isA()), + ); + }); + }); } diff --git a/test/apis/twitch_gql_streams_test.dart b/test/apis/twitch_gql_streams_test.dart new file mode 100644 index 00000000..00888e9f --- /dev/null +++ b/test/apis/twitch_gql_streams_test.dart @@ -0,0 +1,129 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchGqlApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchGqlApi(dio); + }); + + Map streamEdge({ + required String id, + required String login, + required int viewers, + String? cursor, + }) { + return { + 'cursor': cursor ?? 'cursor-$id', + 'node': { + 'id': id, + 'title': '$login live', + 'viewersCount': viewers, + 'createdAt': '2026-01-01T00:00:00Z', + 'language': 'en', + 'game': {'id': '1', 'name': 'Game'}, + 'broadcaster': { + 'id': id, + 'login': login, + 'displayName': login, + }, + }, + }; + } + + group('getStreams', () { + test('parses freeform tag page and cursor', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'streams': { + 'edges': [ + streamEdge(id: '1', login: 'a', viewers: 10), + streamEdge(id: '2', login: 'b', viewers: 5), + ], + 'pageInfo': {'hasNextPage': true}, + }, + }, + }), + data: Matchers.any, + ); + + final page = await api.getStreams( + freeformTags: ['Deutsch'], + sort: StreamViewerSort.viewersAsc, + ); + + expect(page.data.map((s) => s.userLogin), ['a', 'b']); + expect(page.data.map((s) => s.viewerCount), [10, 5]); + expect(page.pagination['cursor'], 'cursor-2'); + }); + + test('throws IntegrityCheckException on integrity errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'failed integrity check'}, + ], + 'data': {'streams': null}, + }), + data: Matchers.any, + ); + + await expectLater( + api.getStreams( + freeformTags: ['Deutsch'], + cursor: 'page-2', + authToken: 'web', + integrityToken: 'stale', + deviceId: 'device', + ), + throwsA(isA()), + ); + }); + + test('sends Client-Integrity and X-Device-Id when provided', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'streams': { + 'edges': [streamEdge(id: '1', login: 'a', viewers: 1)], + 'pageInfo': {'hasNextPage': false}, + }, + }, + }), + data: Matchers.any, + headers: { + 'Client-ID': Matchers.any, + 'Content-Type': Matchers.any, + 'User-Agent': Matchers.any, + 'Authorization': 'OAuth web-token', + 'X-Device-Id': 'dev-123', + 'Client-Integrity': 'integrity-token', + }, + ); + + final page = await api.getStreams( + freeformTags: ['English'], + sort: StreamViewerSort.viewersDesc, + cursor: 'after-1', + authToken: 'web-token', + integrityToken: 'integrity-token', + deviceId: 'dev-123', + ); + + expect(page.data, hasLength(1)); + expect(page.pagination, isEmpty); + }); + }); +} diff --git a/test/apis/twitch_integrity_test.dart b/test/apis/twitch_integrity_test.dart new file mode 100644 index 00000000..a07ec031 --- /dev/null +++ b/test/apis/twitch_integrity_test.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; + +String _tokenFor(Map payload) { + final encoded = base64Url.encode(utf8.encode(jsonEncode(payload))); + return 'v4.public.$encoded.fakesig'; +} + +void main() { + group('TwitchIntegrity', () { + test('detects is_bad_bot string flag', () { + final token = _tokenFor({ + 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', + 'device_id': 'abc123', + 'is_bad_bot': 'true', + 'exp': '2099-01-01T00:00:00Z', + }); + + expect(TwitchIntegrity.isBadBot(token), isTrue); + expect(TwitchIntegrity.deviceId(token), 'abc123'); + expect(TwitchIntegrity.expirationMs(token), isNotNull); + }); + + test('detects is_bad_bot bool flag', () { + final token = _tokenFor({ + 'is_bad_bot': true, + 'device_id': 'dev', + }); + expect(TwitchIntegrity.isBadBot(token), isTrue); + }); + + test('returns false when not flagged', () { + final token = _tokenFor({ + 'is_bad_bot': 'false', + 'device_id': 'dev', + }); + expect(TwitchIntegrity.isBadBot(token), isFalse); + }); + }); +} diff --git a/test/screens/home/stream_list/stream_list_store_test.dart b/test/screens/home/stream_list/stream_list_store_test.dart new file mode 100644 index 00000000..1a066c66 --- /dev/null +++ b/test/screens/home/stream_list/stream_list_store_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + +void main() { + setUpAll(() { + registerFallbackValue(StreamViewerSort.viewersDesc); + registerFallbackValue([]); + }); + + late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; + late AuthStore authStore; + late SettingsStore settingsStore; + + StreamTwitch stream(String id, int viewers) => StreamTwitch( + id, + 'user$id', + 'User$id', + 'game', + 'Game', + 'title', + viewers, + '2026-01-01T00:00:00Z', + 'https://cdn/live_user_$id-{width}x{height}.jpg', + ); + + Future waitUntilIdle(ListStore store) async { + await Future.delayed(Duration.zero); + for (var i = 0; i < 50 && store.isLoading; i++) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); + authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi); + settingsStore = SettingsStore()..streamLanguages = []; + + when(() => api.getUserInfo()).thenAnswer( + (_) async => const UserTwitch( + '123', + 'me', + 'Me', + 'https://cdn/me.png', + ), + ); + when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( + (_) async => [], + ); + }); + + group('ListStore tag GQL pagination', () { + test('keeps server ASC order across GQL pages', () async { + when( + () => gqlApi.getStreams( + freeformTags: any(named: 'freeformTags'), + languages: any(named: 'languages'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + first: any(named: 'first'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((invocation) async { + final cursor = invocation.namedArguments[#cursor] as String?; + if (cursor == null) { + return StreamsTwitch( + [stream('1', 3), stream('2', 7)], + const {'cursor': 'c1'}, + ); + } + expect(cursor, 'c1'); + return StreamsTwitch( + [stream('3', 12), stream('4', 20)], + const {}, + ); + }); + + settingsStore.streamViewerSort = StreamViewerSort.viewersAsc; + + final store = ListStore( + authStore: authStore, + settingsStore: settingsStore, + twitchApi: api, + twitchGqlApi: gqlApi, + listType: ListType.tag, + freeformTag: 'Deutsch', + ); + + await waitUntilIdle(store); + + expect(store.streams.map((s) => s.userId), ['1', '2']); + expect(store.hasMore, isTrue); + + await store.getStreams(); + await waitUntilIdle(store); + + expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']); + expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]); + expect(store.hasMore, isFalse); + verifyNever( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ); + + store.dispose(); + }); + + test('falls back to Helix when GQL page-2 integrity fails', () async { + var gqlCalls = 0; + when( + () => gqlApi.getStreams( + freeformTags: any(named: 'freeformTags'), + languages: any(named: 'languages'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + first: any(named: 'first'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((invocation) async { + gqlCalls++; + final cursor = invocation.namedArguments[#cursor] as String?; + if (cursor == null) { + return StreamsTwitch( + [stream('1', 100)], + const {'cursor': 'c1'}, + ); + } + throw const IntegrityCheckException('failed integrity check'); + }); + + when( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ).thenAnswer( + (_) async => StreamsTwitch( + [stream('1', 100), stream('9', 50)], + const {}, + ), + ); + + final store = ListStore( + authStore: authStore, + settingsStore: settingsStore, + twitchApi: api, + twitchGqlApi: gqlApi, + listType: ListType.tag, + freeformTag: 'Deutsch', + ); + + await waitUntilIdle(store); + expect(store.streams.map((s) => s.userId), ['1']); + + await store.getStreams(); + await waitUntilIdle(store); + + expect(gqlCalls, greaterThanOrEqualTo(2)); + expect(store.streams.map((s) => s.userId), ['1', '9']); + verify( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ).called(1); + + store.dispose(); + }); + }); +} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 9d11c922..4897d3ad 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:mocktail/mocktail.dart'; @@ -178,5 +181,88 @@ void main() { expect(userStore.cachedIsFollowing('999'), isFalse); }); + + test('runWithGqlIntegrity refreshes token and retries', () async { + var calls = 0; + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer( + (_) async => ( + token: 'fresh-integrity', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final userStore = makeStore(hasModeratorScopes: false); + final result = await userStore.runWithGqlIntegrity( + gqlToken: 'web-token', + action: ({authToken, integrityToken, deviceId}) async { + calls++; + expect(authToken, 'web-token'); + expect(deviceId, isNotEmpty); + if (calls == 1) { + throw const IntegrityCheckException('failed integrity check'); + } + expect(integrityToken, 'fresh-integrity'); + return 'ok'; + }, + ); + + expect(result, 'ok'); + expect(calls, 2); + verify( + () => gqlApi.fetchIntegrityToken( + authToken: 'web-token', + deviceId: any(named: 'deviceId'), + ), + ).called(1); + }); + + test('runWithGqlIntegrity rethrows when no gqlToken', () async { + final userStore = makeStore(hasModeratorScopes: false); + + await expectLater( + userStore.runWithGqlIntegrity( + gqlToken: null, + action: ({authToken, integrityToken, deviceId}) async { + throw const IntegrityCheckException('failed integrity check'); + }, + ), + throwsA(isA()), + ); + verifyNever( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ); + }); + + test('applyWebIntegrity stores device id for later GQL calls', () async { + final userStore = makeStore(hasModeratorScopes: false); + await userStore.applyWebIntegrity( + GqlIntegrityCredentials( + integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ + 'is_bad_bot': 'false', + 'device_id': 'webview-device', + })))}.sig', + deviceId: 'webview-device', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final deviceId = await userStore.runWithGqlIntegrity( + gqlToken: 'web-token', + action: ({authToken, integrityToken, deviceId}) async { + expect(deviceId, 'webview-device'); + expect(integrityToken, isNotNull); + return deviceId!; + }, + ); + expect(deviceId, 'webview-device'); + }); }); } From 9ced2fe212f2d4b271a6c43cc0e17902b4c50ff6 Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 07:32:16 +0200 Subject: [PATCH 63/67] release v5.2.9 Co-authored-by: Cursor --- assets/release-notes.md | 20 +++++++++++--------- pubspec.yaml | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index d25f1cef..570b0ede 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,14 @@ +## v5.2.9 + +October 7, 2026 + +### Bug Fixes + +- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. +- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. + +--- + ## v5.2.8 October 7, 2026 @@ -16,12 +27,3 @@ October 1, 2026 - Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page. ---- - -## v5.2.6 - -October 1, 2026 - -### Bug Fixes - -- Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally. diff --git a/pubspec.yaml b/pubspec.yaml index 559d5f51..f865ee47 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.8+78 +version: 5.2.9+79 environment: sdk: ^3.9.0 From 9a08c401365a9929da6aa25f5666e308d1b1d89a Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 09:49:45 +0200 Subject: [PATCH 64/67] Add Follow/Unfollow button under the channel player. Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path. Co-authored-by: Cursor --- lib/screens/channel/channel.dart | 9 + .../channel/video/channel_info_header.dart | 63 +++++ lib/widgets/follow_button.dart | 206 ++++++++++++++ lib/widgets/follow_channel_controller.dart | 150 +++++++++++ lib/widgets/user_actions_modal.dart | 242 +++++++---------- .../follow_channel_controller_test.dart | 254 ++++++++++++++++++ 6 files changed, 774 insertions(+), 150 deletions(-) create mode 100644 lib/screens/channel/video/channel_info_header.dart create mode 100644 lib/widgets/follow_button.dart create mode 100644 lib/widgets/follow_channel_controller.dart create mode 100644 test/widgets/follow_channel_controller_test.dart diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index c65c0e4b..1013812b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,6 +6,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/channel_info_header.dart'; import 'package:frosty/screens/channel/video/native_video.dart'; import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; @@ -746,6 +747,14 @@ class _VideoChatState extends State aspectRatio: 16 / 9, child: SizedBox(), ), + ChannelInfoHeader( + videoStore: _videoStore, + authStore: context.authStore, + userId: widget.userId, + displayName: widget.userName, + isInSharedChatMode: + _chatStore.isInSharedChatMode, + ), ], Expanded(child: chat), ], diff --git a/lib/screens/channel/video/channel_info_header.dart b/lib/screens/channel/video/channel_info_header.dart new file mode 100644 index 00000000..0cf77aa2 --- /dev/null +++ b/lib/screens/channel/video/channel_info_header.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/video/stream_info_bar.dart'; +import 'package:frosty/screens/channel/video/video_player_interface.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/follow_button.dart'; + +/// Channel metadata + follow control shown under the player (portrait). +class ChannelInfoHeader extends StatelessWidget { + final VideoPlayerInterface videoStore; + final AuthStore authStore; + final String userId; + final String displayName; + final bool isInSharedChatMode; + + const ChannelInfoHeader({ + super.key, + required this.videoStore, + required this.authStore, + required this.userId, + required this.displayName, + this.isInSharedChatMode = false, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Observer( + builder: (context) { + final streamInfo = videoStore.streamInfo; + final offline = streamInfo == null; + + return Row( + spacing: 8, + children: [ + Expanded( + child: StreamInfoBar( + streamInfo: streamInfo, + offlineChannelInfo: videoStore.offlineChannelInfo, + displayName: displayName, + isCompact: true, + isOffline: offline, + isInSharedChatMode: isInSharedChatMode, + showTextShadows: false, + tooltipTriggerMode: TooltipTriggerMode.longPress, + ), + ), + FollowButton( + authStore: authStore, + userId: userId, + displayName: displayName, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart new file mode 100644 index 00000000..c90b8c5a --- /dev/null +++ b/lib/widgets/follow_button.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/follow_channel_controller.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:frosty/widgets/integrity_webview_dialog.dart'; + +/// Compact Follow / Unfollow control for channel headers. +class FollowButton extends StatefulWidget { + final AuthStore authStore; + final String userId; + final String displayName; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; + + const FollowButton({ + super.key, + required this.authStore, + required this.userId, + required this.displayName, + this.initialIsFollowing, + this.onFollowChanged, + }); + + @override + State createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State { + late final FollowChannelController _controller = FollowChannelController( + authStore: widget.authStore, + userId: widget.userId, + initialIsFollowing: widget.initialIsFollowing, + onChanged: () { + if (mounted) setState(() {}); + }, + ); + + @override + void initState() { + super.initState(); + _controller.init(); + } + + Future _onPressed() async { + if (!_controller.canFollow || + _controller.isFollowing == null || + _controller.actionInProgress) { + return; + } + + final wasFollowing = _controller.isFollowing!; + + try { + final nowFollowing = await _controller.toggle(); + if (!mounted) return; + widget.onFollowChanged?.call(nowFollowing); + _showResultSnack(nowFollowing); + } on FollowNeedsWebSessionException { + if (!mounted) return; + await _promptLinkWebSession(); + } on IntegrityCheckException { + if (!mounted) return; + await _handleIntegrity(wasFollowing: wasFollowing); + } on ApiException catch (e) { + if (!mounted) return; + _showErrorSnack(e.message); + } catch (_) { + if (!mounted) return; + _showErrorSnack( + wasFollowing + ? 'Could not unfollow ${widget.displayName}' + : 'Could not follow ${widget.displayName}', + ); + } + } + + Future _handleIntegrity({required bool wasFollowing}) async { + final gqlToken = widget.authStore.gqlToken; + if (gqlToken == null) { + _controller.clearActionInProgress(); + await _promptLinkWebSession(); + return; + } + + final credentials = await showIntegrityWebViewDialog( + context: context, + gqlToken: gqlToken, + ); + if (!mounted) return; + + if (credentials == null) { + _controller.clearActionInProgress(); + _showErrorSnack('Follow cancelled — Twitch verification required'); + return; + } + + try { + final nowFollowing = await _controller.retryAfterIntegrity( + wasFollowing: wasFollowing, + credentials: credentials, + ); + if (!mounted) return; + widget.onFollowChanged?.call(nowFollowing); + _showResultSnack(nowFollowing); + } on ApiException catch (e) { + if (!mounted) return; + _showErrorSnack(e.message); + } catch (_) { + if (!mounted) return; + _showErrorSnack( + wasFollowing + ? 'Could not unfollow ${widget.displayName}' + : 'Could not follow ${widget.displayName}', + ); + } + } + + Future _promptLinkWebSession() { + return showDialog( + context: context, + builder: (dialogContext) => FrostyDialog( + title: 'Web session required', + message: + 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', + actions: [ + TextButton( + onPressed: Navigator.of(dialogContext).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => LoginWebView()), + ); + }, + child: const Text('Log in'), + ), + ], + ), + ); + } + + void _showResultSnack(bool nowFollowing) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.displayName}' + : 'Unfollowed ${widget.displayName}', + centered: false, + ), + ), + ); + } + + void _showErrorSnack(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage(message: message, centered: false), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_controller.shouldShow) { + return const SizedBox.shrink(); + } + + final following = _controller.isFollowing == true; + final busy = _controller.loading || _controller.actionInProgress; + + return FilledButton.tonal( + onPressed: busy ? null : _onPressed, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Icon( + following + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + size: 16, + ), + Text(following ? 'Unfollow' : 'Follow'), + ], + ), + ); + } +} diff --git a/lib/widgets/follow_channel_controller.dart b/lib/widgets/follow_channel_controller.dart new file mode 100644 index 00000000..46c45ff2 --- /dev/null +++ b/lib/widgets/follow_channel_controller.dart @@ -0,0 +1,150 @@ +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; + +/// Shared follow/unfollow state + GQL mutations for channel UI. +/// +/// Owns loading/action flags and the session cache via [AuthStore.user]. +/// Callers handle UI (integrity WebView, login prompt, snackbars). +class FollowChannelController { + final AuthStore authStore; + final String userId; + final bool? initialIsFollowing; + final void Function() onChanged; + + bool? isFollowing; + bool loading = false; + bool actionInProgress = false; + + FollowChannelController({ + required this.authStore, + required this.userId, + this.initialIsFollowing, + required this.onChanged, + }); + + bool get isSelf => + authStore.user.details?.id != null && + authStore.user.details!.id == userId; + + bool get canFollow => authStore.isLoggedIn && !isSelf; + + /// Whether the follow control should be visible (loading or known state). + bool get shouldShow => canFollow && (loading || isFollowing != null); + + void init() { + if (!canFollow) return; + if (initialIsFollowing != null) { + isFollowing = initialIsFollowing; + onChanged(); + return; + } + loadFollowState(); + } + + Future loadFollowState() async { + final cached = authStore.user.cachedIsFollowing(userId); + if (cached != null) { + isFollowing = cached; + onChanged(); + return; + } + + loading = true; + onChanged(); + try { + final following = await authStore.user.isFollowingChannel(userId); + isFollowing = following; + } catch (_) { + // Leave isFollowing null so the control stays hidden. + } finally { + loading = false; + onChanged(); + } + } + + /// Toggles follow. Returns the new following state on success. + /// + /// Throws [IntegrityCheckException] when a WebView integrity mint is needed, + /// [ApiException] for API errors, or [StateError] when not ready / no token. + Future toggle() async { + if (actionInProgress || isFollowing == null) { + throw StateError('Follow toggle not ready'); + } + + final gqlToken = authStore.gqlToken; + if (gqlToken == null) { + throw const FollowNeedsWebSessionException(); + } + + final following = isFollowing!; + actionInProgress = true; + onChanged(); + + try { + await _runMutation(following: following, gqlToken: gqlToken); + } catch (_) { + actionInProgress = false; + onChanged(); + rethrow; + } + + final nowFollowing = !following; + isFollowing = nowFollowing; + actionInProgress = false; + onChanged(); + return nowFollowing; + } + + /// After a WebView integrity mint, apply credentials and retry [toggle]'s + /// intended mutation (still based on the pre-toggle [wasFollowing] state). + Future retryAfterIntegrity({ + required bool wasFollowing, + required GqlIntegrityCredentials credentials, + }) async { + final gqlToken = authStore.gqlToken; + if (gqlToken == null) { + throw const FollowNeedsWebSessionException(); + } + + actionInProgress = true; + onChanged(); + + try { + await authStore.user.applyWebIntegrity(credentials); + await _runMutation(following: wasFollowing, gqlToken: gqlToken); + } catch (_) { + actionInProgress = false; + onChanged(); + rethrow; + } + + final nowFollowing = !wasFollowing; + isFollowing = nowFollowing; + actionInProgress = false; + onChanged(); + return nowFollowing; + } + + Future _runMutation({ + required bool following, + required String gqlToken, + }) async { + if (following) { + await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken); + } else { + await authStore.user.follow(targetId: userId, gqlToken: gqlToken); + } + } + + void clearActionInProgress() { + if (!actionInProgress) return; + actionInProgress = false; + onChanged(); + } +} + +/// Thrown when follow needs a linked Twitch web (GQL) session. +class FollowNeedsWebSessionException implements Exception { + const FollowNeedsWebSessionException(); +} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index fad22c21..19bc1f90 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -6,6 +6,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/follow_channel_controller.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/integrity_webview_dialog.dart'; import 'package:provider/provider.dart'; @@ -47,178 +48,119 @@ class UserActionsModal extends StatefulWidget { } class _UserActionsModalState extends State { - bool? _isFollowing; - bool _followLoading = false; - bool _followActionInProgress = false; - - bool get _isSelf => - widget.authStore.user.details?.id != null && - widget.authStore.user.details!.id == widget.userId; - - bool get _canFollow => - widget.authStore.isLoggedIn && !_isSelf; + late final FollowChannelController _follow = FollowChannelController( + authStore: widget.authStore, + userId: widget.userId, + initialIsFollowing: widget.initialIsFollowing, + onChanged: () { + if (mounted) setState(() {}); + }, + ); @override void initState() { super.initState(); - if (!_canFollow) return; - if (widget.initialIsFollowing != null) { - _isFollowing = widget.initialIsFollowing; - } else { - _loadFollowState(); - } - } - - Future _loadFollowState() async { - final cached = widget.authStore.user.cachedIsFollowing(widget.userId); - if (cached != null) { - setState(() => _isFollowing = cached); - return; - } - - setState(() => _followLoading = true); - try { - final following = await widget.authStore.user.isFollowingChannel( - widget.userId, - ); - if (mounted) { - setState(() { - _isFollowing = following; - _followLoading = false; - }); - } - } catch (_) { - if (mounted) { - setState(() => _followLoading = false); - } - } + _follow.init(); } Future _toggleFollow() async { - if (_followActionInProgress || _isFollowing == null) return; + if (!_follow.canFollow || + _follow.isFollowing == null || + _follow.actionInProgress) { + return; + } + final wasFollowing = _follow.isFollowing!; + + try { + final nowFollowing = await _follow.toggle(); + if (!mounted) return; + widget.onFollowChanged?.call(nowFollowing); + _popWithSnack( + nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + ); + } on FollowNeedsWebSessionException { + if (!mounted) return; + await _promptLinkWebSession(); + } on IntegrityCheckException { + if (!mounted) return; + await _handleIntegrity(wasFollowing: wasFollowing); + } on ApiException catch (e) { + if (!mounted) return; + _showErrorSnack(e.message); + } catch (_) { + if (!mounted) return; + _showErrorSnack( + wasFollowing + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + ); + } + } + + Future _handleIntegrity({required bool wasFollowing}) async { final gqlToken = widget.authStore.gqlToken; if (gqlToken == null) { + _follow.clearActionInProgress(); await _promptLinkWebSession(); return; } - final following = _isFollowing!; - setState(() => _followActionInProgress = true); + final credentials = await showIntegrityWebViewDialog( + context: context, + gqlToken: gqlToken, + ); + if (!mounted) return; - try { - await _runFollowMutation(following: following, gqlToken: gqlToken); - } on IntegrityCheckException { - if (!mounted) return; - // Dio /integrity is almost always bad_bot — mint via WebView like Xtra. - final credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; - if (credentials == null) { - setState(() => _followActionInProgress = false); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: AlertMessage( - message: 'Follow cancelled — Twitch verification required', - centered: false, - ), - ), - ); - return; - } - - try { - await widget.authStore.user.applyWebIntegrity(credentials); - await _runFollowMutation(following: following, gqlToken: gqlToken); - } on ApiException catch (e) { - if (!mounted) return; - setState(() => _followActionInProgress = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: e.message, centered: false), - ), - ); - return; - } catch (_) { - if (!mounted) return; - setState(() => _followActionInProgress = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage( - message: following - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', - centered: false, - ), - ), - ); - return; - } - } on ApiException catch (e) { - if (!mounted) return; - setState(() => _followActionInProgress = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: e.message, centered: false), - ), - ); - return; - } catch (_) { - if (!mounted) return; - setState(() => _followActionInProgress = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage( - message: following - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', - centered: false, - ), - ), - ); + if (credentials == null) { + _follow.clearActionInProgress(); + _showErrorSnack('Follow cancelled — Twitch verification required'); return; } - if (!mounted) return; - - final nowFollowing = !following; - setState(() { - _isFollowing = nowFollowing; - _followActionInProgress = false; - }); - widget.onFollowChanged?.call(nowFollowing); + try { + final nowFollowing = await _follow.retryAfterIntegrity( + wasFollowing: wasFollowing, + credentials: credentials, + ); + if (!mounted) return; + widget.onFollowChanged?.call(nowFollowing); + _popWithSnack( + nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + ); + } on ApiException catch (e) { + if (!mounted) return; + _showErrorSnack(e.message); + } catch (_) { + if (!mounted) return; + _showErrorSnack( + wasFollowing + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + ); + } + } + void _popWithSnack(String message) { final messenger = ScaffoldMessenger.maybeOf(context); Navigator.pop(context); messenger?.showSnackBar( SnackBar( - content: AlertMessage( - message: nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - centered: false, - ), + content: AlertMessage(message: message, centered: false), ), ); } - Future _runFollowMutation({ - required bool following, - required String gqlToken, - }) async { - if (following) { - await widget.authStore.user.unfollow( - targetId: widget.userId, - gqlToken: gqlToken, - ); - } else { - await widget.authStore.user.follow( - targetId: widget.userId, - gqlToken: gqlToken, - ); - } + void _showErrorSnack(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage(message: message, centered: false), + ), + ); } Future _promptLinkWebSession() { @@ -249,7 +191,7 @@ class _UserActionsModalState extends State { @override Widget build(BuildContext context) { - final showFollowTile = _canFollow && (_followLoading || _isFollowing != null); + final showFollowTile = _follow.shouldShow; return ListView( primary: false, @@ -280,23 +222,23 @@ class _UserActionsModalState extends State { ), if (showFollowTile) ListTile( - leading: _followLoading || _followActionInProgress + leading: _follow.loading || _follow.actionInProgress ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( - _isFollowing == true + _follow.isFollowing == true ? Icons.heart_broken_rounded : Icons.favorite_rounded, ), title: Text( - _isFollowing == true + _follow.isFollowing == true ? 'Unfollow ${widget.name}' : 'Follow ${widget.name}', ), - enabled: !_followLoading && !_followActionInProgress, + enabled: !_follow.loading && !_follow.actionInProgress, onTap: _toggleFollow, ), if (widget.authStore.isLoggedIn) diff --git a/test/widgets/follow_channel_controller_test.dart b/test/widgets/follow_channel_controller_test.dart new file mode 100644 index 00000000..bdc88570 --- /dev/null +++ b/test/widgets/follow_channel_controller_test.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/user_store.dart'; +import 'package:frosty/widgets/follow_channel_controller.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockAuthStore extends Mock implements AuthStore {} + +class MockTwitchApi extends Mock implements TwitchApi {} + +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + +void main() { + late MockAuthStore authStore; + late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; + late UserStore userStore; + var notifyCount = 0; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + authStore = MockAuthStore(); + api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); + notifyCount = 0; + + when(() => api.getUserInfo()).thenAnswer( + (_) async => const UserTwitch( + '12345', + 'testuser', + 'TestUser', + 'https://cdn/profile.png', + ), + ); + when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( + (_) async => [], + ); + + userStore = UserStore( + twitchApi: api, + twitchGqlApi: gqlApi, + hasModeratorScopes: () => false, + ); + await userStore.init(); + + when(() => authStore.user).thenReturn(userStore); + when(() => authStore.isLoggedIn).thenReturn(true); + when(() => authStore.gqlToken).thenReturn('web-token'); + }); + + FollowChannelController makeController({ + String userId = '999', + bool? initialIsFollowing, + }) { + return FollowChannelController( + authStore: authStore, + userId: userId, + initialIsFollowing: initialIsFollowing, + onChanged: () => notifyCount++, + ); + } + + group('FollowChannelController', () { + test('hides when logged out', () { + when(() => authStore.isLoggedIn).thenReturn(false); + final controller = makeController(initialIsFollowing: false); + controller.init(); + + expect(controller.canFollow, isFalse); + expect(controller.shouldShow, isFalse); + }); + + test('hides for own channel', () { + final controller = makeController(userId: '12345'); + controller.init(); + + expect(controller.isSelf, isTrue); + expect(controller.canFollow, isFalse); + expect(controller.shouldShow, isFalse); + }); + + test('uses initialIsFollowing without Helix lookup', () { + final controller = makeController(initialIsFollowing: true); + controller.init(); + + expect(controller.isFollowing, isTrue); + expect(controller.shouldShow, isTrue); + verifyNever( + () => api.isFollowingChannel( + userId: any(named: 'userId'), + broadcasterId: any(named: 'broadcasterId'), + ), + ); + }); + + test('loads follow state from Helix and caches', () async { + when( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).thenAnswer((_) async => false); + + final controller = makeController(); + controller.init(); + await Future.delayed(Duration.zero); + + expect(controller.isFollowing, isFalse); + expect(controller.loading, isFalse); + expect(controller.shouldShow, isTrue); + expect(userStore.cachedIsFollowing('999'), isFalse); + }); + + test('toggle follows then unfollows', () async { + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + when( + () => gqlApi.unfollowUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + + final controller = makeController(initialIsFollowing: false); + controller.init(); + + expect(await controller.toggle(), isTrue); + expect(controller.isFollowing, isTrue); + expect(userStore.cachedIsFollowing('999'), isTrue); + + expect(await controller.toggle(), isFalse); + expect(controller.isFollowing, isFalse); + expect(userStore.cachedIsFollowing('999'), isFalse); + }); + + test('toggle throws when web session missing', () async { + when(() => authStore.gqlToken).thenReturn(null); + final controller = makeController(initialIsFollowing: false); + controller.init(); + + await expectLater( + controller.toggle(), + throwsA(isA()), + ); + }); + + test('toggle rethrows integrity and clears action flag', () async { + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenThrow(const IntegrityCheckException('failed integrity check')); + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenThrow(const IntegrityCheckException('bad bot')); + + final controller = makeController(initialIsFollowing: false); + controller.init(); + + await expectLater( + controller.toggle(), + throwsA(isA()), + ); + expect(controller.actionInProgress, isFalse); + expect(controller.isFollowing, isFalse); + }); + + test('retryAfterIntegrity applies credentials and follows', () async { + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + + final controller = makeController(initialIsFollowing: false); + controller.init(); + + final okToken = + 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ + 'is_bad_bot': 'false', + 'device_id': 'device-abc', + })))}.sig'; + + final nowFollowing = await controller.retryAfterIntegrity( + wasFollowing: false, + credentials: GqlIntegrityCredentials( + integrityToken: okToken, + deviceId: 'device-abc', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + expect(nowFollowing, isTrue); + expect(controller.isFollowing, isTrue); + verify( + () => gqlApi.followUser( + targetId: '999', + authToken: 'web-token', + integrityToken: okToken, + deviceId: 'device-abc', + ), + ).called(1); + }); + + test('retryAfterIntegrity rejects bad_bot integrity', () async { + final controller = makeController(initialIsFollowing: false); + controller.init(); + + final badToken = + 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ + 'is_bad_bot': 'true', + 'device_id': 'device-abc', + })))}.sig'; + + await expectLater( + controller.retryAfterIntegrity( + wasFollowing: false, + credentials: GqlIntegrityCredentials( + integrityToken: badToken, + deviceId: 'device-abc', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ), + throwsA(isA()), + ); + expect(controller.actionInProgress, isFalse); + }); + }); +} From 59290b23b148b0dedad6a8657936ce85e8aeb052 Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 09:49:52 +0200 Subject: [PATCH 65/67] release v5.3.0 Co-authored-by: Cursor --- assets/release-notes.md | 21 ++++++++++----------- pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 570b0ede..fd17a0e7 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.3.0 + +October 12, 2026 + +### New Features + +- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. + +--- + ## v5.2.9 October 7, 2026 @@ -16,14 +26,3 @@ October 7, 2026 ### New Features - Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session). - ---- - -## v5.2.7 - -October 1, 2026 - -### Bug Fixes - -- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page. - diff --git a/pubspec.yaml b/pubspec.yaml index f865ee47..5d8af15a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.2.9+79 +version: 5.3.0+80 environment: sdk: ^3.9.0 From eb39a4ac003d18910addb01f128945d0374a1d67 Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 10:21:21 +0200 Subject: [PATCH 66/67] Auto-recover native player after Source errors. Screen-off and network switches often leave ExoPlayer dead without a 4xx status; refresh automatically (and on app resume) instead of waiting for Reload. Co-authored-by: Cursor --- .../channel/video/native_video_store.dart | 60 ++++++++++++++----- .../video/playback_error_recovery.dart | 27 +++++++++ .../video/playback_error_recovery_test.dart | 56 +++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 lib/screens/channel/video/playback_error_recovery.dart create mode 100644 test/screens/channel/video/playback_error_recovery_test.dart diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 1af97005..31077620 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -15,6 +15,7 @@ import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; +import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; import 'package:frosty/screens/channel/video/stream_info_poller.dart'; import 'package:frosty/screens/channel/video/video_timing_constants.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -683,26 +684,44 @@ abstract class NativeVideoStoreBase return; } - // 4xx on the HLS fetch means the current playback session is gone — a - // fresh GQL token + new query params can land on a different CDN edge - // with a working manifest. Skip the user-facing error and retry - // immediately (handleRefresh updates _lastRefreshTime itself). - if (statusCode != null && - statusCode >= 400 && - statusCode < 500 && - !_refreshCapReached()) { + // Mid-stream failures (expired HLS session, ExoPlayer "Source error" + // after screen-off / network switch, CDN blips). A fresh token + reload + // usually fixes these — only pin the Reload overlay once retries are + // spent. 4xx refreshes immediately; network/source waits briefly so the + // interface can come back. + if (shouldAutoRecoverFromPlayerError( + refreshCapReached: _refreshCapReached(), + errorCode: errorCode, + )) { if (_userPaused) { // Don't yank a user-paused stream back to life — the refresh would // reload with autoplay. Defer until they resume. _pendingRefreshOnResume = true; return; } + final attempt = _totalRefreshAttempts + 1; + final delay = playerErrorRetryDelay(statusCode: statusCode); debugPrint( - 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' - 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + 'NativeVideoStore: player error' + '${statusCode != null ? ' (HTTP $statusCode)' : ''}' + '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' + 'auto-refresh in ${delay.inMilliseconds}ms ' + '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', ); - _totalRefreshAttempts++; - _handleRefresh(userInitiated: false); + _totalRefreshAttempts = attempt; + _loading = true; + _error = null; + _initRetryTimer?.cancel(); + final generation = _initGeneration; + if (delay == Duration.zero) { + _handleRefresh(userInitiated: false); + } else { + _initRetryTimer = Timer(delay, () { + if (_disposed || generation != _initGeneration) return; + // _handleRefresh parks behind _pendingRefreshOnResume when paused. + _handleRefresh(userInitiated: false); + }); + } return; } @@ -1310,9 +1329,22 @@ abstract class NativeVideoStoreBase if (_isInPipMode) return; _highLatencyCount = 0; updateStreamInfo(forceUpdate: true); - if (!_userPaused && !_initializing && _controller != null) { - _controller!.play(); + + if (_userPaused || _initializing) return; + + // After screen-off / a network drop the native player is often in an + // error state (ExoPlayer "Source error"). play() alone cannot revive a + // dead session — refresh like the Reload button would. + if (_error != null || + _pendingRefreshOnResume || + _controller == null || + !_controllerInitialized) { + _pendingRefreshOnResume = false; + unawaited(_handleRefresh(userInitiated: false)); + return; } + + _controller!.play(); } @override diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart new file mode 100644 index 00000000..72b83815 --- /dev/null +++ b/lib/screens/channel/video/playback_error_recovery.dart @@ -0,0 +1,27 @@ +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player +/// error instead of pinning the Reload overlay. +/// +/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a +/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset] +/// has its own dispose+rebuild path and is excluded here. +bool shouldAutoRecoverFromPlayerError({ + required bool refreshCapReached, + String? errorCode, +}) { + if (refreshCapReached) return false; + if (errorCode == 'mediaServicesWereReset') return false; + return true; +} + +/// Delay before an automatic refresh for [statusCode]. +/// +/// Token/CDN 4xx can refresh immediately; network/source failures get a short +/// pause so the interface can come back after a switch or screen wake. +Duration playerErrorRetryDelay({int? statusCode}) { + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + return Duration.zero; + } + return VideoTimingConstants.initRetryDelay; +} diff --git a/test/screens/channel/video/playback_error_recovery_test.dart b/test/screens/channel/video/playback_error_recovery_test.dart new file mode 100644 index 00000000..adc80903 --- /dev/null +++ b/test/screens/channel/video/playback_error_recovery_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; +import 'package:frosty/screens/channel/video/video_timing_constants.dart'; + +void main() { + group('shouldAutoRecoverFromPlayerError', () { + test('recovers Source error / generic failures under the refresh cap', () { + expect( + shouldAutoRecoverFromPlayerError(refreshCapReached: false), + isTrue, + ); + expect( + shouldAutoRecoverFromPlayerError( + refreshCapReached: false, + errorCode: null, + ), + isTrue, + ); + }); + + test('stops once the refresh cap is reached', () { + expect( + shouldAutoRecoverFromPlayerError(refreshCapReached: true), + isFalse, + ); + }); + + test('defers mediaServicesWereReset to its dedicated rebuild path', () { + expect( + shouldAutoRecoverFromPlayerError( + refreshCapReached: false, + errorCode: 'mediaServicesWereReset', + ), + isFalse, + ); + }); + }); + + group('playerErrorRetryDelay', () { + test('refreshes 4xx token failures immediately', () { + expect(playerErrorRetryDelay(statusCode: 403), Duration.zero); + expect(playerErrorRetryDelay(statusCode: 404), Duration.zero); + }); + + test('waits briefly for network / Source errors without status', () { + expect( + playerErrorRetryDelay(), + VideoTimingConstants.initRetryDelay, + ); + expect( + playerErrorRetryDelay(statusCode: 500), + VideoTimingConstants.initRetryDelay, + ); + }); + }); +} From 378c230815d47a364e04543c2a04b7e63bd839bb Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 10:21:21 +0200 Subject: [PATCH 67/67] release v5.3.1 Co-authored-by: Cursor --- assets/release-notes.md | 20 ++++++++++---------- pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index fd17a0e7..6d112234 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,13 @@ +## v5.3.1 + +October 12, 2026 + +### Bug Fixes + +- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh. + +--- + ## v5.3.0 October 12, 2026 @@ -16,13 +26,3 @@ October 7, 2026 - Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. - Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. - ---- - -## v5.2.8 - -October 7, 2026 - -### New Features - -- Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session). diff --git a/pubspec.yaml b/pubspec.yaml index 5d8af15a..1899d872 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # 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: 5.3.0+80 +version: 5.3.1+81 environment: sdk: ^3.9.0