Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 378c230815 | |||
| eb39a4ac00 |
5 changed files with 140 additions and 25 deletions
|
|
@ -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
|
## v5.3.0
|
||||||
|
|
||||||
October 12, 2026
|
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 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.
|
- 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).
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import 'package:frosty/models/playback_access_token.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/screens/channel/video/chat_latency_sync.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/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/stream_info_poller.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -683,26 +684,44 @@ abstract class NativeVideoStoreBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx on the HLS fetch means the current playback session is gone — a
|
// Mid-stream failures (expired HLS session, ExoPlayer "Source error"
|
||||||
// fresh GQL token + new query params can land on a different CDN edge
|
// after screen-off / network switch, CDN blips). A fresh token + reload
|
||||||
// with a working manifest. Skip the user-facing error and retry
|
// usually fixes these — only pin the Reload overlay once retries are
|
||||||
// immediately (handleRefresh updates _lastRefreshTime itself).
|
// spent. 4xx refreshes immediately; network/source waits briefly so the
|
||||||
if (statusCode != null &&
|
// interface can come back.
|
||||||
statusCode >= 400 &&
|
if (shouldAutoRecoverFromPlayerError(
|
||||||
statusCode < 500 &&
|
refreshCapReached: _refreshCapReached(),
|
||||||
!_refreshCapReached()) {
|
errorCode: errorCode,
|
||||||
|
)) {
|
||||||
if (_userPaused) {
|
if (_userPaused) {
|
||||||
// Don't yank a user-paused stream back to life — the refresh would
|
// Don't yank a user-paused stream back to life — the refresh would
|
||||||
// reload with autoplay. Defer until they resume.
|
// reload with autoplay. Defer until they resume.
|
||||||
_pendingRefreshOnResume = true;
|
_pendingRefreshOnResume = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final attempt = _totalRefreshAttempts + 1;
|
||||||
|
final delay = playerErrorRetryDelay(statusCode: statusCode);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh '
|
'NativeVideoStore: player error'
|
||||||
'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})',
|
'${statusCode != null ? ' (HTTP $statusCode)' : ''}'
|
||||||
|
'${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, '
|
||||||
|
'auto-refresh in ${delay.inMilliseconds}ms '
|
||||||
|
'(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})',
|
||||||
);
|
);
|
||||||
_totalRefreshAttempts++;
|
_totalRefreshAttempts = attempt;
|
||||||
_handleRefresh(userInitiated: false);
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1310,9 +1329,22 @@ abstract class NativeVideoStoreBase
|
||||||
if (_isInPipMode) return;
|
if (_isInPipMode) return;
|
||||||
_highLatencyCount = 0;
|
_highLatencyCount = 0;
|
||||||
updateStreamInfo(forceUpdate: true);
|
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
|
@override
|
||||||
|
|
|
||||||
27
lib/screens/channel/video/playback_error_recovery.dart
Normal file
27
lib/screens/channel/video/playback_error_recovery.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.9.0
|
sdk: ^3.9.0
|
||||||
|
|
|
||||||
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal file
56
test/screens/channel/video/playback_error_recovery_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue