From 9a08c401365a9929da6aa25f5666e308d1b1d89a Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 09:49:45 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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