From 57e7f73aa856061f169936b24f37adde093cbf38 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 14:45:20 -0400 Subject: [PATCH 01/46] perf(chat): cut per-flush rebuild and decode costs in message list every 200ms flush shifted all sliver indices (reverse list), and the per-row ObjectKeys without findChildIndexCallback made the framework deactivate and re-inflate every built row instead of moving it. add the callback so elements move, and cache ChatMessage widget instances per message (Expando) so unchanged rows skip build() entirely via the identical-widget short-circuit. IRCMessage.renderRevision invalidates rows the CLEARCHAT/CLEARMSG handlers mutate in place. also decode emotes/badges at display size (memCacheHeight = logical size x devicePixelRatio) instead of the CDN's 3x-4x intrinsic size: several-fold less image memory and raster cost, animation preserved (ResizeImage applies the target size to the whole codec). Co-Authored-By: Claude Fable 5 --- lib/models/irc.dart | 26 +++ lib/screens/channel/chat/chat.dart | 157 +++++++++++++++---- lib/widgets/frosty_cached_network_image.dart | 14 ++ test/models/irc_test.dart | 38 +++++ 4 files changed, 207 insertions(+), 28 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e7f7ce78..7009ca23 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -42,6 +42,12 @@ class IRCMessage { final VoidCallback? actionCallback; final String? actionLabel; + /// Bumped whenever the message is mutated in place after creation (the + /// moderation marking in [clearChat]/[clearMessage]), so render output + /// cached against this message's identity knows to invalidate. Purely a + /// render-cache signal — not part of the IRC model. + int renderRevision = 0; + IRCMessage({ required this.raw, required this.command, @@ -85,6 +91,7 @@ class IRCMessage { if (message.user == bannedUser) { // Mark the message for removal. messages[i].command = Command.clearChat; + messages[i].renderRevision++; // If timed-out, indicate the duration. if (banDuration != null) { @@ -98,6 +105,7 @@ class IRCMessage { if (bufferedMessage.user == bannedUser) { // Mark the message for removal. bufferedMessages[i].command = Command.clearChat; + bufferedMessages[i].renderRevision++; // If timed-out, indicate the duration. if (banDuration != null) { @@ -119,6 +127,7 @@ class IRCMessage { for (var i = 0; i < messages.length; i++) { if (messages[i].tags['id'] == targetId) { messages[i].command = Command.clearMessage; + messages[i].renderRevision++; break; } } @@ -127,6 +136,7 @@ class IRCMessage { for (var i = 0; i < bufferedMessages.length; i++) { if (bufferedMessages[i].tags['id'] == targetId) { bufferedMessages[i].command = Command.clearMessage; + bufferedMessages[i].renderRevision++; break; } } @@ -591,6 +601,10 @@ class IRCMessage { imageUrl: emote.url, height: emote.height != null ? emote.height! * emoteScale : emoteSize, width: emote.width != null ? emote.width! * emoteScale : emoteSize, + memCacheHeight: _memCacheDimension( + context, + emote.height != null ? emote.height! * emoteScale : emoteSize, + ), useFade: false, ), ), @@ -748,6 +762,7 @@ class IRCMessage { double? size, Color? backgroundColor, bool? isSvg, + int? memCacheHeight, }) { if (backgroundColor != null) { return ColoredBox( @@ -756,6 +771,7 @@ class IRCMessage { imageUrl: badge.url, height: size, width: size, + memCacheHeight: memCacheHeight, useFade: false, ), ); @@ -766,6 +782,7 @@ class IRCMessage { imageUrl: badge.url, height: size, width: size, + memCacheHeight: memCacheHeight, useFade: false, ); } @@ -801,6 +818,7 @@ class IRCMessage { size: size, backgroundColor: backgroundColor, isSvg: isSvg, + memCacheHeight: _memCacheDimension(context, size), ), ), ); @@ -825,6 +843,9 @@ class IRCMessage { imageUrl: emote.url, height: height, width: width, + // Decode at the rendered size instead of the CDN's 3x-4x intrinsic + // size. Height-only preserves the aspect ratio. + memCacheHeight: _memCacheDimension(context, height), useFade: false, placeholder: (context, url) => const SizedBox(), ), @@ -832,6 +853,11 @@ class IRCMessage { ); } + /// Converts a layout dimension to a physical-pixel decode cap for the + /// in-memory image cache. + static int _memCacheDimension(BuildContext context, double logicalSize) => + (logicalSize * MediaQuery.devicePixelRatioOf(context)).round(); + static TextSpan _createTextSpan({ required String text, required bool launchExternal, diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 80ceda50..ffe2e0ee 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/models/irc.dart'; +import 'package:frosty/models/user.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'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; @@ -16,6 +18,27 @@ import 'package:frosty/widgets/frosty_scrollbar.dart'; /// ahead of fast scroll-back without holding the entire history. const _chatCacheExtent = ScrollCacheExtent.pixels(500.0); +/// Cached [ChatMessage] instances keyed by message identity. Returning the +/// identical widget instance across list rebuilds lets the framework skip the +/// row's build entirely (`Element.updateChild` short-circuits on identical +/// widgets), so a batch flush only builds the genuinely new messages instead +/// of regenerating every visible row's spans. Entries are GC'd together with +/// their messages; [IRCMessage.renderRevision] invalidates rows the moderation +/// handlers mutate in place. MobX reactivity is unaffected — each row's +/// Observer subscription lives on its element, not its widget instance. +final _messageWidgetCache = Expando<_CachedMessageWidget>(); + +/// Merged-mode cache, kept separate because merged rows carry per-active-tab +/// parameters that must invalidate the cached widget when they change. +final _mergedMessageWidgetCache = Expando<_CachedMessageWidget>(); + +class _CachedMessageWidget { + const _CachedMessageWidget(this.widget, this.revision); + + final ChatMessage widget; + final int revision; +} + class Chat extends StatelessWidget { final ChatStore chatStore; final EdgeInsetsGeometry? listPadding; @@ -176,6 +199,10 @@ class Chat extends StatelessWidget { ScrollController scrollController, double bottomPadding, ) { + final messages = chatStore.renderMessages; + // Built lazily on the first keyed-child lookup of a rebuild. + Map? sliverIndexByMessage; + return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( @@ -184,23 +211,51 @@ class Chat extends StatelessWidget { addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, - itemCount: chatStore.renderMessages.length, + itemCount: messages.length, + // Every appended batch shifts all sliver indices (the list is reversed, + // so new messages land at index 0). Mapping a row's key back to its new + // index here lets the framework *move* the existing element — without + // it, the key mismatch at each shifted index would deactivate and + // re-inflate every built row on every flush. + findChildIndexCallback: (key) { + final lookup = sliverIndexByMessage ??= { + for (var i = 0; i < messages.length; i++) + messages[messages.length - 1 - i]: i, + }; + return lookup[(key as ObjectKey).value]; + }, 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, - ); + final ircMessage = messages[messages.length - 1 - index]; + return _cachedChatMessage(ircMessage); }, ); } + /// Returns the (cached) widget for [ircMessage], building it on first + /// render. Keyed 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. + Widget _cachedChatMessage(IRCMessage ircMessage) { + final cached = _messageWidgetCache[ircMessage]; + if (cached != null && + cached.revision == ircMessage.renderRevision && + identical(cached.widget.chatStore, chatStore)) { + return cached.widget; + } + + final widget = ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: chatStore, + ); + _messageWidgetCache[ircMessage] = _CachedMessageWidget( + widget, + ircMessage.renderRevision, + ); + return widget; + } + Widget _buildMergedList( ScrollController scrollController, double bottomPadding, @@ -208,6 +263,8 @@ class Chat extends StatelessWidget { final mergedMessages = chatTabsStore!.mergedMessages; final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; final currentChannelId = chatTabsStore!.activeTab.channelId; + // Built lazily on the first keyed-child lookup of a rebuild. + Map? sliverIndexByMessage; return ListView.builder( reverse: true, @@ -218,29 +275,73 @@ class Chat extends StatelessWidget { scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, + // See _buildNormalList — moves existing elements across index shifts. + findChildIndexCallback: (key) { + final lookup = sliverIndexByMessage ??= { + for (var i = 0; i < mergedMessages.length; i++) + mergedMessages[mergedMessages.length - 1 - i].ircMessage: i, + }; + return lookup[(key as ObjectKey).value]; + }, itemBuilder: (context, index) { final merged = mergedMessages[mergedMessages.length - 1 - index]; - return ChatMessage( - key: ObjectKey(merged.ircMessage), - ircMessage: merged.ircMessage, - chatStore: merged.chatStore, - inputChatStore: chatStore, - onActivateSourceTab: () { - final tabs = chatTabsStore!.tabs; - final idx = tabs.indexWhere( - (t) => t.channelId == merged.chatStore.channelId, - ); - if (idx != -1 && idx != chatTabsStore!.activeTabIndex) { - chatTabsStore!.setActiveTab(idx, silent: true); - } - }, - overrideChannelIdToUserTwitch: channelIdToUserTwitch, - overrideCurrentChannelId: currentChannelId, + return _cachedMergedChatMessage( + merged, + channelIdToUserTwitch, + currentChannelId, ); }, ); } + /// Merged-mode variant of [_cachedChatMessage]. The extra identity checks + /// invalidate the cached widget when the active tab (send target) or the + /// combined channel-profile map changes. + Widget _cachedMergedChatMessage( + MergedMessage merged, + Map channelIdToUserTwitch, + String currentChannelId, + ) { + final ircMessage = merged.ircMessage; + final cached = _mergedMessageWidgetCache[ircMessage]; + final cachedWidget = cached?.widget; + if (cached != null && + cachedWidget != null && + cached.revision == ircMessage.renderRevision && + identical(cachedWidget.chatStore, merged.chatStore) && + identical(cachedWidget.inputChatStore, chatStore) && + identical( + cachedWidget.overrideChannelIdToUserTwitch, + channelIdToUserTwitch, + ) && + cachedWidget.overrideCurrentChannelId == currentChannelId) { + return cachedWidget; + } + + final widget = ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: merged.chatStore, + inputChatStore: chatStore, + onActivateSourceTab: () { + final tabs = chatTabsStore!.tabs; + final idx = tabs.indexWhere( + (t) => t.channelId == merged.chatStore.channelId, + ); + if (idx != -1 && idx != chatTabsStore!.activeTabIndex) { + chatTabsStore!.setActiveTab(idx, silent: true); + } + }, + overrideChannelIdToUserTwitch: channelIdToUserTwitch, + overrideCurrentChannelId: currentChannelId, + ); + _mergedMessageWidgetCache[ircMessage] = _CachedMessageWidget( + widget, + ircMessage.renderRevision, + ); + return widget; + } + Widget _buildResumeScrollButton() { return Builder( builder: (context) { diff --git a/lib/widgets/frosty_cached_network_image.dart b/lib/widgets/frosty_cached_network_image.dart index 0f1f9934..4a2def96 100644 --- a/lib/widgets/frosty_cached_network_image.dart +++ b/lib/widgets/frosty_cached_network_image.dart @@ -8,6 +8,16 @@ class FrostyCachedNetworkImage extends StatelessWidget { final String? cacheKey; final double? width; final double? height; + + /// Decode size caps (physical pixels) for the in-memory image. Without + /// these, images decode and stay resident at their intrinsic size — emote + /// CDNs serve 3x-4x assets (~112-128 px) that render in ~28 lp boxes, so + /// capping the decode cuts memory and raster cost several-fold. Applies to + /// animated codecs too (all frames decode at the target size; animation is + /// preserved). Providing only one axis keeps the aspect ratio. + final int? memCacheWidth; + final int? memCacheHeight; + final Color? color; final BlendMode? colorBlendMode; final Widget Function(BuildContext, String)? placeholder; @@ -21,6 +31,8 @@ class FrostyCachedNetworkImage extends StatelessWidget { this.cacheKey, this.width, this.height, + this.memCacheWidth, + this.memCacheHeight, this.color, this.colorBlendMode, this.placeholder, @@ -36,6 +48,8 @@ class FrostyCachedNetworkImage extends StatelessWidget { cacheKey: cacheKey, width: width, height: height, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, color: color, colorBlendMode: colorBlendMode, placeholder: placeholder, diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 56b1cf99..a3851935 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -339,6 +339,35 @@ void main() { expect(affectedMessage.tags['ban-duration'], '300'); }); + test('bumps renderRevision only on affected messages', () { + final clearChatMsg = IRCMessage( + raw: '', + command: Command.clearChat, + tags: {'ban-duration': '600'}, + message: 'testuser', + ); + bufferedMessages = [ + IRCMessage.fromString(basicPrivmsg), // from testuser + ]; + + IRCMessage.clearChat( + messages: messages, + bufferedMessages: bufferedMessages, + ircMessage: clearChatMsg, + ); + + // Mutated-in-place messages must signal render caches to invalidate. + expect( + messages.where((m) => m.user == 'testuser').first.renderRevision, + 1, + ); + expect(bufferedMessages.first.renderRevision, 1); + expect( + messages.where((m) => m.user == 'moduser').first.renderRevision, + 0, + ); + }); + test('clears entire chat when no target user', () { final clearAllMsg = IRCMessage( raw: '', @@ -408,6 +437,15 @@ void main() { messages.firstWhere((m) => m.tags['id'] == 'msg-2').command, Command.clearMessage, ); + // Mutated-in-place message must signal render caches to invalidate. + expect( + messages.firstWhere((m) => m.tags['id'] == 'msg-2').renderRevision, + 1, + ); + expect( + messages.firstWhere((m) => m.tags['id'] == 'msg-1').renderRevision, + 0, + ); // Other messages unchanged expect( messages.firstWhere((m) => m.tags['id'] == 'msg-1').command, From 979f46edfe49a3ae980f17201a0df389b826e170 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 14:45:38 -0400 Subject: [PATCH 02/46] perf(chat): pause chat work when hidden or backgrounded inactive IndexedStack tabs stay mounted and their animated emotes keep decoding frames forever; wrap them in TickerMode(enabled: false) so the Image widget pauses animated codecs until the tab is active again. while the app is backgrounded no frames are produced but dart timers keep firing, so the 200ms flush timers (one per tab, plus the merged render timer) were pure battery cost. pause them on AppLifecycleState.paused and resume with an immediate catch-up flush; messages keep buffering with the existing overflow drain bounding growth. Co-Authored-By: Claude Fable 5 --- lib/screens/channel/channel.dart | 6 +++ .../channel/chat/stores/chat_store.dart | 52 +++++++++++++++---- .../channel/chat/stores/chat_tabs_store.dart | 26 ++++++++++ .../channel/chat/widgets/chat_tabs.dart | 31 +++++++---- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index c66dae36..6c130a8a 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -128,6 +128,12 @@ class _VideoChatState extends State if (state == AppLifecycleState.resumed) { _videoStore.handleAppResume(); + _chatTabsStore.onAppResumed(); + } else if (state == AppLifecycleState.paused) { + // No frames are produced while backgrounded, but Dart timers keep + // firing — stop the chat flush timers until resume. (Messages keep + // buffering; PiP hides chat anyway, so pausing during PiP is safe.) + _chatTabsStore.onAppPaused(); } } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 356d09db..a905c5fb 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -189,6 +189,12 @@ abstract class ChatStoreBase with Store { /// The periodic timer used for batching chat message re-renders. Timer? _messageBufferTimer; + /// Whether the app is backgrounded. Gates the message flush timer — no + /// frames are produced while paused, so the 5x/s wakeups would be pure + /// battery cost. Messages keep parsing into [messageBuffer] and are flushed + /// on resume (with the overflow drain in [_handleIRCData] bounding growth). + var _isAppPaused = false; + /// The list of chat messages to add once autoscroll is resumed. /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. final messageBuffer = ObservableList(); @@ -817,8 +823,10 @@ abstract class ChatStoreBase with Store { continue; } - if (!_autoScroll) { - // While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak. + if (!_autoScroll || _isAppPaused) { + // While the flush is held (scrolled up, or app backgrounded), + // occasionally move messages from the buffer to the messages to + // prevent unbounded buffer growth. if (messageBuffer.length >= _messagesToRemove) { _messages.addAll(messageBuffer); messageBuffer.clear(); @@ -841,13 +849,7 @@ abstract class ChatStoreBase with Store { ); // Activate the message buffer. - // Cancel any existing timer before creating a new one to prevent duplicates on reconnect. - _messageBufferTimer?.cancel(); - // Create a timer that will add messages from the buffer every 200 milliseconds. - _messageBufferTimer = Timer.periodic( - const Duration(milliseconds: 200), - (timer) => addMessages(), - ); + _startMessageBufferTimer(); // Set up 7TV real-time listener (assets already fetched in connectToChat) if (settings.show7TVEmotes) { @@ -1251,6 +1253,38 @@ abstract class ChatStoreBase with Store { } } + /// (Re)starts the periodic buffer flush. Cancels any existing timer first + /// to prevent duplicates on reconnect; no-ops while the app is backgrounded + /// ([onAppResumed] restarts it). + void _startMessageBufferTimer() { + _messageBufferTimer?.cancel(); + _messageBufferTimer = null; + if (_isAppPaused) return; + // Create a timer that will add messages from the buffer every 200 milliseconds. + _messageBufferTimer = Timer.periodic( + const Duration(milliseconds: 200), + (timer) => addMessages(), + ); + } + + /// Stops the flush timer while the app is backgrounded. + void onAppPaused() { + _isAppPaused = true; + _messageBufferTimer?.cancel(); + _messageBufferTimer = null; + } + + /// Restarts the flush timer after [onAppPaused] and flushes the backlog + /// immediately so chat is current on the first visible frame. + void onAppResumed() { + if (!_isAppPaused) return; + _isAppPaused = false; + if (_hasConnected) { + _startMessageBufferTimer(); + addMessages(); + } + } + @action void addMessages() { _flushDueDelayedCallbacks(); diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index ee095442..5b06cb78 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -407,6 +407,32 @@ abstract class ChatTabsStoreBase with Store { } } + /// Forwards app-pause to every activated tab and stops the merged render + /// timer — no frames are produced while the app is backgrounded, so the + /// periodic flush/recompute work would be pure battery cost. + void onAppPaused() { + _mergedRenderTimer?.cancel(); + _mergedRenderTimer = null; + for (final tab in _tabs) { + tab.chatStore?.onAppPaused(); + } + } + + /// Resumes tab flush timers and (if merged mode is active) the merged + /// render timer after [onAppPaused]. + void onAppResumed() { + for (final tab in _tabs) { + tab.chatStore?.onAppResumed(); + } + if (mergedMode && _mergedRenderTimer == null) { + _refreshMergedMessages(); + _mergedRenderTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refreshMergedMessages(), + ); + } + } + /// Re-enables auto-scroll and jumps to the latest message in merged view. @action void resumeMergedScroll() { diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 490ce0df..0321169b 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -112,17 +112,26 @@ class ChatTabs extends StatelessWidget { ) : IndexedStack( index: activeIndex, - children: tabs.map((tabInfo) { - if (tabInfo.chatStore == null) { - return const SizedBox.shrink(); - } - return Chat( - key: ValueKey(tabInfo.channelId), - chatStore: tabInfo.chatStore!, - listPadding: adjustedPadding, - onAddChat: () => _handleAddChat(context), - ); - }).toList(), + children: [ + for (var i = 0; i < tabs.length; i++) + if (tabs[i].chatStore == null) + const SizedBox.shrink() + else + // IndexedStack children stay mounted (and keep + // animating) when hidden. Disabling TickerMode + // pauses inactive tabs' animated emotes — the + // Image widget stops its frame-decode loop while + // the ticker mode is off and resumes on switch. + TickerMode( + key: ValueKey(tabs[i].channelId), + enabled: i == activeIndex, + child: Chat( + chatStore: tabs[i].chatStore!, + listPadding: adjustedPadding, + onAddChat: () => _handleAddChat(context), + ), + ), + ], ), ), if (showTabBar) From 16f2722a5b917fb1597f94cf39b913ed3e1a6df3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 14:45:38 -0400 Subject: [PATCH 03/46] perf: async startup cache sweep, memoize chat name color the orphaned-cache-file sweep ran a synchronous recursive listSync on the UI isolate at startup against a cache that can hold 10k files; use the async listing instead. memoize adjustChatNameColor keyed by (color, background, target contrast) since chat repeats the same handful of palette colors thousands of times and low-contrast ones pay a 16-step search per call. Co-Authored-By: Claude Fable 5 --- lib/cache_manager.dart | 5 ++++- lib/utils.dart | 18 ++++++++++++++++++ test/utils_test.dart | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/cache_manager.dart b/lib/cache_manager.dart index b3d61f48..d6d30879 100644 --- a/lib/cache_manager.dart +++ b/lib/cache_manager.dart @@ -23,7 +23,10 @@ class CustomCacheManager { return; } - final allFiles = cacheDir.listSync(recursive: true).toList(); + // Async listing — the cache can hold up to maxNrOfCacheObjects (10k) + // files, and a synchronous recursive listing that large would block the + // UI isolate during startup. + final allFiles = await cacheDir.list(recursive: true).toList(); await _repo.open(); final cachedObjects = await _repo.getAllObjects(); diff --git a/lib/utils.dart b/lib/utils.dart index f0b35278..dbf2e0fd 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -24,6 +24,12 @@ double _contrastRatio(Color a, Color b) { return (hi + 0.05) / (lo + 0.05); } +/// Memoized results for [adjustChatNameColor]. Chat renders the same handful +/// of Twitch palette colors thousands of times per session, and every call +/// costs luminance math (plus a 16-step search for low-contrast colors) — the +/// answer only depends on this key, so compute it once per combination. +final _adjustedNameColorCache = <(int, int, double), Color>{}; + /// Adjusts [color] just enough to reach [targetContrast] on the app's background. /// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls /// back to theme.colorScheme.surface. @@ -44,6 +50,18 @@ Color adjustChatNameColor( bg = theme.colorScheme.surface; } + final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast); + final cached = _adjustedNameColorCache[cacheKey]; + if (cached != null) return cached; + + final adjusted = _adjustNameColorUncached(color, bg, targetContrast); + // Custom hex name colors are unbounded in theory; keep the cache small. + if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear(); + _adjustedNameColorCache[cacheKey] = adjusted; + return adjusted; +} + +Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) { if (_contrastRatio(color, bg) >= targetContrast) return color; final hsl0 = HSLColor.fromColor(color); diff --git a/test/utils_test.dart b/test/utils_test.dart index 846540f6..7d359aad 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -198,6 +198,45 @@ void main() { expect(adjustedColor, isNot(const Color(0xFF00FF00))); }); + testWidgets('memoized results are stable and keyed by background', + (tester) async { + late Color first; + late Color second; + late Color onLightBackground; + const darkBackground = Color(0xFF121212); + const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg + + await tester.pumpWidget( + buildTestWidget( + scaffoldBackground: darkBackground, + child: Builder( + builder: (context) { + // Repeated calls with identical inputs must return the same + // adjusted color (second call is served from the memo cache). + first = adjustChatNameColor(context, lowContrastColor); + second = adjustChatNameColor(context, lowContrastColor); + // A different background must not be served the dark-background + // cache entry: near-black already passes on white, so it should + // come back unchanged. + onLightBackground = adjustChatNameColor( + context, + lowContrastColor, + background: const Color(0xFFFFFFFF), + ); + return const SizedBox(); + }, + ), + ), + ); + + expect(second, first); + expect( + first.computeLuminance(), + greaterThan(lowContrastColor.computeLuminance()), + ); + expect(onLightBackground, lowContrastColor); + }); + testWidgets('handles transparent scaffold background', (tester) async { late Color adjustedColor; const darkColor = Color(0xFF000000); From 86afc4a6c8769aaadd979fb9675ddeac19d33794 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 14:45:38 -0400 Subject: [PATCH 04/46] docs: add chat performance audit backlog remaining ranked findings from the 2026-07 chat perf audit (verified against the pinned flutter 3.44.2 sdk and package sources), plus keep/tune verdicts for the existing optimizations so they don't get relitigated. Co-Authored-By: Claude Fable 5 --- docs/chat-perf-backlog.md | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/chat-perf-backlog.md diff --git a/docs/chat-perf-backlog.md b/docs/chat-perf-backlog.md new file mode 100644 index 00000000..53e3dc7e --- /dev/null +++ b/docs/chat-perf-backlog.md @@ -0,0 +1,93 @@ +# Chat performance backlog + +Findings from a chat-rendering performance audit (2026-07), verified against the pinned Flutter 3.44.2 SDK source and installed package sources. The top-ranked confirmed items were fixed on `tc/chat-perf`; this file tracks what remains, ranked by expected impact. Line references are to the SDK/package versions pinned at audit time. + +## Fixed in the initial pass (for context) + +- `findChildIndexCallback` on both chat ListViews. Per-item `ObjectKey`s without it forced deactivate + re-inflate of every built row on every 200 ms flush (`SliverMultiBoxAdaptorElement.performRebuild` matches purely by position when `findIndexByKey` returns null; key mismatch at each shifted index lands in the remount branch of `Element.updateChild`, framework.dart:4047). +- Per-message widget caching (`Expando` in `chat.dart`). Identical widget instances short-circuit `Element.updateChild` (framework.dart:4014), so a flush only builds genuinely new messages instead of re-running `generateSpan` for every visible row. Invalidated via `IRCMessage.renderRevision` (bumped by the in-place CLEARCHAT/CLEARMSG mutations). +- `memCacheHeight` for emotes and badges (decode at display size instead of the CDN's 3x-4x intrinsic size; verified animation-safe: `ResizeImage` applies the target size to the codec, all frames included). +- `TickerMode(enabled: false)` on inactive `IndexedStack` tabs (the `Image` widget pauses animated codecs when TickerMode is off, image.dart:1151; IndexedStack's own `Visibility(maintainAnimation: true)` wrapper never disables tickers). +- Flush timers pause while the app is backgrounded (`onAppPaused`/`onAppResumed` through `ChatTabsStore` -> each tab's `ChatStore` + the merged render timer). Frames stop when paused (`SchedulerBinding.framesEnabled`) but Dart timers don't, so the 5 Hz wakeups were pure battery cost. +- Startup orphaned-cache sweep switched from `listSync` (synchronous I/O on the UI isolate, up to ~10k files) to async `list()`. +- `adjustChatNameColor` memoized keyed by (color, background, target contrast). + +## Remaining items, ranked + +### 1. "Animate emotes" setting (first-frame / static mode) + +The biggest remaining battery lever. Every mounted animated emote keeps a decode -> `scheduleFrameCallback` loop running indefinitely while any listener is attached (cached_network_image ships its own copy of the SDK's `MultiFrameImageStreamCompleter` with identical behavior: `multi_image_stream_completer.dart:165`). "Mounted" includes rows inside the 500 px `scrollCacheExtent`, not just visible ones. The engine never idles at vsync while a busy chat is on screen. OS reduce-motion already pauses this (Image honors `MediaQuery.disableAnimations`), and inactive tabs now pause via TickerMode, but the visible tab still animates dozens of emotes continuously. + +Options: a setting that requests static CDN variants (7TV/BTTV/FFZ all serve static URLs), or a one-frame custom ImageProvider. Effort: M-L. User-visible setting. + +### 2. Disconnect IRC while backgrounded (Android) + +Flush timers now pause, but every IRC line is still received and parsed while backgrounded (50+ msg/s in busy channels = continuous CPU + radio with zero visible output; on iOS the OS suspends the runtime anyway, so this is Android + iOS-PiP). Twitch's own app disconnects chat in background. Disconnect after a grace period, reconnect on resume, optionally backfill via recent-messages when the setting is on. Behavior-visible: a scrollback gap while backgrounded. Effort: M. + +### 3. Skip flushing inactive tabs + +Offstage IndexedStack tabs still lay out and rebuild their lists on every 200 ms flush (builds are cheap now with widget caching, but `RenderStack.performLayout` is O(N) over all children, stack.dart:678, and each offstage viewport lays out its cache-extent rows). Gate `addMessages` for non-active tabs; the buffer overflow drain already bounds growth. Must keep flushing when `mergedMode` is on (merged view reads each tab's `messages`), and `hasUnreadMessages` should read the buffer too. Effort: M. + +### 4. BTTV badge SVGs bypass the disk cache + +`SvgPicture.network` (irc.dart `_createBadgeWidget`) uses plain http with a ref-counted in-memory picture cache (flutter_svg 2.3.0 loaders.dart:436; vector_graphics `_livePictureCache` evicts when the last widget goes away). Badges re-download every session and can re-fetch within a session. Route bytes through `CustomCacheManager` and feed `SvgPicture.string`/a bytes loader. Effort: S-M. + +### 5. Merged-mode dirty check + +`_refreshMergedMessages` collects + sorts up to tabs x 100 items and assigns a new list identity every 200 ms tick even when no new messages arrived, re-firing the merged list observer at a constant 5 Hz. Track per-tab message counts and short-circuit when unchanged. Effort: S. + +### 6. Trim IRCMessage memory (drop `raw` retention) + +Each message retains the full raw IRC line (0.5-1 KB) plus substring copies in `tags`, the `split` word list, and an unconditionally allocated (usually empty) `localEmotes` map: roughly 1.5-3 KB per message, ~8-15 MB per tab at the 5000 cap, ~100 MB order across 10 busy tabs. Verify nothing consumes `raw` after parse and drop or lazily derive it; skip allocating empty `localEmotes`. Effort: S-M. + +### 7. Dispose TapGestureRecognizers from generateSpan + +`TextSpan.recognizer` contract says the owner must dispose (text_span.dart:124), and nothing does. Verified low severity: an idle recognizer holds no timers, arena entries, or router routes (those attach on pointer-down only), so undisposed ones are plain GC-able garbage, and widget caching now creates them once per message instead of per rebuild. Remaining real defect: a row rebuild between pointer-down and up swaps recognizers and silently drops that tap. Proper fix needs recognizer ownership with a dispose hook (stateful row or registry keyed to message eviction). Effort: M. + +### 8. Lighter tap targets than per-emote InkWell + +Each emote/badge InkWell eagerly builds ~7 widgets, a FocusNode, and a global focus-highlight listener (ink_well.dart:929, build at :1386); Tooltips on historical/shared-chat badges each add a global pointer route consulted on every pointer event (raw_tooltip.dart:793). Mostly a first-build cost now that rows are cached; if profiling still shows element bloat, swap emotes to `GestureDetector` (loses the ripple, which never realistically fires in chat). Effort: S. + +### 9. memCache sizing for remaining image sites + +Not yet covered: the shared-chat source avatar (raw `CachedNetworkImage` with `imageBuilder` in irc.dart, needs verification of how memCacheWidth interacts with imageBuilder's provider), `ProfilePicture`, and the emote menu panels. Effort: S. + +### 10. Adaptive CDN density selection + +Emote URLs still request max density (Twitch `3.0`, BTTV `3x`, FFZ `4x`, 7TV largest non-AVIF) and rely on decode-time downscaling. Picking the 2x variant when DPR x display-size allows would also save bandwidth and disk. Tradeoff: cache churn when `emoteScale` changes. 7TV note: dims come from `files.first` (1x) while the URL is the largest file; that is intentional (layout at 1x size), keep it. Effort: M. + +### 11. Parse-path micro-optimizations + +All parse-time (once per message, not per frame), verified minor: per-grapheme `regexEmoji.hasMatch` loop in `IRCMessage.fromString` (single-pass scan instead), blocked-users linear list scan per message (use a Set of user ids), muted-words O(words x patterns). Effort: S. + +### 12. errorWidget for failed images + +No `errorWidget`/`errorBuilder` anywhere in lib; failed emote/badge/avatar loads render as silent blanks. Hygiene + debuggability. Effort: S. + +### 13. Screen-reader story for the chat list + +With a screen reader on, a 5 Hz-updating list of WidgetSpan-heavy rows is a semantics hazard (each emote is an unlabeled node; constant re-announcement). Semantics are compiled only when assistive tech is active (object.dart:1397), so this costs nothing today, but consider per-row `Semantics(label:)` flattening and update throttling when semantics are enabled. Effort: M-L. + +### 14. Measurement follow-ups + +- Profile-mode DevTools timeline during a busy raid chat: UI thread (span builds should now be flat between new messages) and raster thread (animated emote layers). +- Memory view: ImageCache pressure before/after memCacheHeight (entry count is the binding cap: 1000 images / 100 MiB defaults, image_cache.dart:18). +- Android `dumpsys batterystats` / Xcode Energy gauge for the background and animated-emote items. +- A benchmark harness feeding recorded IRC traffic through `ChatStore` would make regressions visible in CI; nothing covers chat rendering today. + +## Existing optimizations: verified verdicts (do not relitigate) + +| Optimization | Verdict | +| --- | --- | +| 200 ms flush batching | KEEP (right pattern; interval fine) | +| Render cap 100 while autoscrolling, 5000 buffer + 20% trim | KEEP (trim realloc is per-event, cheap; see item 6 for the per-message footprint) | +| `ObjectKey(ircMessage)` on rows | KEEP, now paired with `findChildIndexCallback` (alone it was actively harmful: forced remounts) | +| `addAutomaticKeepAlives: false` | KEEP (micro; known tradeoff: in-flight ink splash can be culled, SelectionArea rows lose keep-alive) | +| `scrollCacheExtent: 500 px` | KEEP (default is 250, applied both sides; revisit only alongside item 1 since it doubles the live animated-emote set) | +| Disk cache 10k objects / 30 d | KEEP (cleanup is lazy, debounced, SQL-side, bounded to 200 deletes per pass) | +| `useFade: false` + const placeholders on emotes | KEEP | +| `cacheKey` on FrostyCachedNetworkImage | KEEP (live for stream thumbnails' 5-min busting; unused in chat paths by design) | +| ProfilePicture static URL cache | KEEP (unbounded but tiny; known staleness until app restart) | +| IndexedStack tab keep-alive | KEEP, now paired with TickerMode gating | +| Per-message `Opacity` fades | KEEP (compositing layer, not saveLayer; static opacity is cheap and adds a repaint boundary) | +| Muted-word pre-normalization, interaction pause, delayed-callback queue, 60 s socket ping | KEEP | From b2a32d178f31ea62c934f451c30aa836b784463b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 9 Jul 2026 00:13:05 -0700 Subject: [PATCH 05/46] 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 06/46] 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 07/46] 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 08/46] 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 09/46] 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 10/46] 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 11/46] 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 12/46] 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 13/46] 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 14/46] 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 15/46] 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 16/46] 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 17/46] 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 18/46] 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 19/46] 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 20/46] 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 21/46] 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 22/46] 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 23/46] 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 24/46] 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 25/46] 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 26/46] 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 27/46] 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 28/46] 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 29/46] 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 30/46] 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 31/46] 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 32/46] 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 33/46] 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 34/46] 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 35/46] 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 36/46] 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 37/46] 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 38/46] 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 39/46] 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 40/46] 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 41/46] 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 42/46] 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 43/46] 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 44/46] 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 45/46] 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 46/46] 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