diff --git a/assets/release-notes.md b/assets/release-notes.md index 6d112234..570b0ede 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,23 +1,3 @@ -## 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 - -### 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 @@ -26,3 +6,24 @@ 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). + +--- + +## 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/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 1013812b..c65c0e4b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,7 +6,6 @@ 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'; @@ -747,14 +746,6 @@ 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 deleted file mode 100644 index 0cf77aa2..00000000 --- a/lib/screens/channel/video/channel_info_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -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/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 31077620..1af97005 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -15,7 +15,6 @@ 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'; @@ -684,44 +683,26 @@ abstract class NativeVideoStoreBase return; } - // 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, - )) { + // 4xx on the HLS fetch means the current playback session is gone — a + // fresh GQL token + new query params can land on a different CDN edge + // with a working manifest. Skip the user-facing error and retry + // immediately (handleRefresh updates _lastRefreshTime itself). + if (statusCode != null && + statusCode >= 400 && + statusCode < 500 && + !_refreshCapReached()) { if (_userPaused) { // Don't yank a user-paused stream back to life — the refresh would // reload with autoplay. Defer until they resume. _pendingRefreshOnResume = true; return; } - final attempt = _totalRefreshAttempts + 1; - final delay = playerErrorRetryDelay(statusCode: statusCode); debugPrint( - 'NativeVideoStore: player error' - '${statusCode != null ? ' (HTTP $statusCode)' : ''}' - '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' - 'auto-refresh in ${delay.inMilliseconds}ms ' - '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', + 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' + 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', ); - _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); - }); - } + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); return; } @@ -1329,22 +1310,9 @@ abstract class NativeVideoStoreBase if (_isInPipMode) return; _highLatencyCount = 0; updateStreamInfo(forceUpdate: true); - - 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; + if (!_userPaused && !_initializing && _controller != null) { + _controller!.play(); } - - _controller!.play(); } @override diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart deleted file mode 100644 index 72b83815..00000000 --- a/lib/screens/channel/video/playback_error_recovery.dart +++ /dev/null @@ -1,27 +0,0 @@ -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/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart deleted file mode 100644 index c90b8c5a..00000000 --- a/lib/widgets/follow_button.dart +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index 46c45ff2..00000000 --- a/lib/widgets/follow_channel_controller.dart +++ /dev/null @@ -1,150 +0,0 @@ -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 19bc1f90..fad22c21 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -6,7 +6,6 @@ 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'; @@ -48,119 +47,178 @@ class UserActionsModal extends StatefulWidget { } class _UserActionsModalState extends State { - late final FollowChannelController _follow = FollowChannelController( - authStore: widget.authStore, - userId: widget.userId, - initialIsFollowing: widget.initialIsFollowing, - onChanged: () { - if (mounted) setState(() {}); - }, - ); + 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(); - _follow.init(); + 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 (!_follow.canFollow || - _follow.isFollowing == null || - _follow.actionInProgress) { - return; - } + if (_followActionInProgress || _isFollowing == null) 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 credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; + final following = _isFollowing!; + setState(() => _followActionInProgress = true); - if (credentials == null) { - _follow.clearActionInProgress(); - _showErrorSnack('Follow cancelled — Twitch verification required'); + 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, + ), + ), + ); return; } - 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}', - ); - } - } + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); + widget.onFollowChanged?.call(nowFollowing); - void _popWithSnack(String message) { final messenger = ScaffoldMessenger.maybeOf(context); Navigator.pop(context); messenger?.showSnackBar( SnackBar( - content: AlertMessage(message: message, centered: false), + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), ), ); } - void _showErrorSnack(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - 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, + ); + } } Future _promptLinkWebSession() { @@ -191,7 +249,7 @@ class _UserActionsModalState extends State { @override Widget build(BuildContext context) { - final showFollowTile = _follow.shouldShow; + final showFollowTile = _canFollow && (_followLoading || _isFollowing != null); return ListView( primary: false, @@ -222,23 +280,23 @@ class _UserActionsModalState extends State { ), if (showFollowTile) ListTile( - leading: _follow.loading || _follow.actionInProgress + leading: _followLoading || _followActionInProgress ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( - _follow.isFollowing == true + _isFollowing == true ? Icons.heart_broken_rounded : Icons.favorite_rounded, ), title: Text( - _follow.isFollowing == true + _isFollowing == true ? 'Unfollow ${widget.name}' : 'Follow ${widget.name}', ), - enabled: !_follow.loading && !_follow.actionInProgress, + enabled: !_followLoading && !_followActionInProgress, onTap: _toggleFollow, ), if (widget.authStore.isLoggedIn) diff --git a/pubspec.yaml b/pubspec.yaml index 1899d872..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.3.1+81 +version: 5.2.9+79 environment: sdk: ^3.9.0 diff --git a/test/screens/channel/video/playback_error_recovery_test.dart b/test/screens/channel/video/playback_error_recovery_test.dart deleted file mode 100644 index adc80903..00000000 --- a/test/screens/channel/video/playback_error_recovery_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -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, - ); - }); - }); -} diff --git a/test/widgets/follow_channel_controller_test.dart b/test/widgets/follow_channel_controller_test.dart deleted file mode 100644 index bdc88570..00000000 --- a/test/widgets/follow_channel_controller_test.dart +++ /dev/null @@ -1,254 +0,0 @@ -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); - }); - }); -}