From eb39a4ac003d18910addb01f128945d0374a1d67 Mon Sep 17 00:00:00 2001 From: copilot Date: Mon, 12 Oct 2026 10:21:21 +0200 Subject: [PATCH 1/2] 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 2/2] 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