Compare commits

..

2 commits
v5.3.0 ... main

Author SHA1 Message Date
378c230815 release v5.3.1
All checks were successful
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Successful in 7m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
eb39a4ac00 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 <cursoragent@cursor.com>
2026-10-12 10:21:21 +02:00
5 changed files with 140 additions and 25 deletions

View file

@ -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).

View file

@ -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

View file

@ -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;
}

View file

@ -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

View file

@ -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,
);
});
});
}