refactor(player): return an outcome enum from _reloadMediaInPlace

The old bool meant "attempt was current and handled", so callers could
not distinguish a real pre-open failure (old stream still loaded) from
success — the distinction the #1520 recovery needs.
This commit is contained in:
edde746
2026-07-10 19:06:51 +02:00
parent e6ffc54c56
commit d07d849fe4
4 changed files with 54 additions and 24 deletions
@@ -221,7 +221,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
/// every post-open resume point (subtitle-load resume, frame-rate gate /// every post-open resume point (subtitle-load resume, frame-rate gate
/// release) arms track selection without playing, the same way a Watch /// release) arms track selection without playing, the same way a Watch
/// Together-owned start does. The caller owns starting playback. /// Together-owned start does. The caller owns starting playback.
Future<bool> _reloadMediaInPlace({ ///
/// The returned [_MediaReloadOutcome] tells the caller what actually
/// happened: only [_MediaReloadOutcome.failed] means the previous session
/// is still on screen with its (possibly dead) stream; user feedback for
/// failures is shown here unless [showErrorUi] is false.
Future<_MediaReloadOutcome> _reloadMediaInPlace({
required MediaItem metadata, required MediaItem metadata,
int? selectedMediaIndex, int? selectedMediaIndex,
String? selectedMediaSourceId, String? selectedMediaSourceId,
@@ -240,12 +245,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
}) async { }) async {
if (widget.isLive) { if (widget.isLive) {
_clearEpisodeLoadingFlags(); _clearEpisodeLoadingFlags();
return false; return _MediaReloadOutcome.rejected;
} }
final existingPlayer = player; final existingPlayer = player;
if (!mounted || existingPlayer == null || _playbackTransition != _PlaybackTransition.idle) { if (!mounted || existingPlayer == null || _playbackTransition != _PlaybackTransition.idle) {
if (mounted) _clearEpisodeLoadingFlags(); if (mounted) _clearEpisodeLoadingFlags();
return false; return _MediaReloadOutcome.rejected;
} }
_playbackTransition = _PlaybackTransition.reloadingMedia; _playbackTransition = _PlaybackTransition.reloadingMedia;
@@ -294,7 +299,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final cycleWatchTogetherAttachment = watchTogetherWasAttached; final cycleWatchTogetherAttachment = watchTogetherWasAttached;
final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); final wtOwnsStart = _watchTogetherOwnsPlaybackStart();
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex; final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex;
final targetQualityPreset = qualityPreset ?? _selectedQualityPreset; final targetQualityPreset = qualityPreset ?? _selectedQualityPreset;
@@ -323,7 +328,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
} catch (e) { } catch (e) {
appLogger.w('Failed to pause before $reason', error: e); appLogger.w('Failed to pause before $reason', error: e);
} }
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Overlap the old item's stop report with the resolve round-trip; it // Overlap the old item's stop report with the resolve round-trip; it
// is awaited again right before the open below. // is awaited again right before the open below.
@@ -341,7 +346,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
sessionIdentifier: _playbackSessionIdentifier, sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId, transcodeSessionId: _playbackTranscodeSessionId,
); );
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final result = playbackContext.result; final result = playbackContext.result;
final mediaClient = playbackContext.reportingClient; final mediaClient = playbackContext.reportingClient;
final plexClient = mediaClient is PlexClient ? mediaClient : null; final plexClient = mediaClient is PlexClient ? mediaClient : null;
@@ -369,11 +374,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
offlineWatchService: offlineWatchService, offlineWatchService: offlineWatchService,
requested: resumePosition, requested: resumePosition,
); );
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final displayCriteria = result.mediaInfo?.displayCriteria; final displayCriteria = result.mediaInfo?.displayCriteria;
final settingsService = await SettingsService.getInstance(); final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same pre-open frame-rate orchestration as the initial start flow — // Same pre-open frame-rate orchestration as the initial start flow —
// including the Android MPV startup decoder refresh, whose gate is // including the Android MPV startup decoder refresh, whose gate is
@@ -387,7 +392,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
hasVideoUrl: true, hasVideoUrl: true,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(), ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
); );
if (frameRatePlan == null || !isCurrentReload()) return true; if (frameRatePlan == null || !isCurrentReload()) return _MediaReloadOutcome.superseded;
_frameRate.resetForNewItem(); _frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) _frameRate.applied = true; if (frameRatePlan.countsAsApplied) _frameRate.applied = true;
@@ -397,7 +402,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
displayCriteria: displayCriteria, displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
); );
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final openTiming = _playbackOpenTiming( final openTiming = _playbackOpenTiming(
backend: metadata.backend, backend: metadata.backend,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
@@ -411,7 +416,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
unawaited(DiscordRPCService.instance.stopPlayback()); unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback()); unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback()); unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
frameRatePlan.armStartupRefreshGate(currentPlayer); frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
@@ -443,7 +448,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_commitPlaybackSession(session); _commitPlaybackSession(session);
}, },
); );
if (!didOpen || !isCurrentReload()) return true; // A false didOpen means shouldContinue stopped the sequence pre-open
// (open failures throw into the catch below) — superseded either way.
if (!didOpen || !isCurrentReload()) return _MediaReloadOutcome.superseded;
_completionLatch.reset(); _completionLatch.reset();
// Versions/mediaInfo come from the committed session; rebuild so the // Versions/mediaInfo come from the committed session; rebuild so the
@@ -492,7 +499,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
player == currentPlayer, player == currentPlayer,
applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart, applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart,
); );
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
await _releaseFrameRateStartupGate( await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer, currentPlayer: currentPlayer,
@@ -508,7 +515,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
), ),
playbackResumedForStartupFrame: resumeForStartupFrame, playbackResumedForStartupFrame: resumeForStartupFrame,
); );
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same helper as the initial start flow, so any future change lands in // Same helper as the initial start flow, so any future change lands in
// both paths together. // both paths together.
@@ -528,14 +535,14 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
} }
unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt)); unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt));
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
if (_autoPipEnabled) { if (_autoPipEnabled) {
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing));
} }
return true; return _MediaReloadOutcome.opened;
} catch (e) { } catch (e) {
if (!isCurrentReload()) return true; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
_completionLatch.reset(); _completionLatch.reset();
if (!didOpenReplacement) { if (!didOpenReplacement) {
// Nothing was opened: the previous session is still committed, so // Nothing was opened: the previous session is still committed, so
@@ -576,7 +583,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (mounted && showErrorUi) { if (mounted && showErrorUi) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
} }
return true; return didOpenReplacement ? _MediaReloadOutcome.opened : _MediaReloadOutcome.failed;
} finally { } finally {
// Release the reload transition unless a newer flow already took // Release the reload transition unless a newer flow already took
// ownership (a non-reload attempt force-idles it; a newer reload can // ownership (a non-reload attempt force-idles it; a newer reload can
@@ -268,7 +268,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
if (!mounted || currentPlayer == null || !_isPlayerInitialized) return; if (!mounted || currentPlayer == null || !_isPlayerInitialized) return;
_recordLifecycleState('resumed', action: 'tv_background_suspend_reload'); _recordLifecycleState('resumed', action: 'tv_background_suspend_reload');
final reloaded = await _reloadMediaInPlace( final outcome = await _reloadMediaInPlace(
metadata: _currentMetadata, metadata: _currentMetadata,
resumePosition: resumePosition, resumePosition: resumePosition,
preserveCurrentTrackSelection: true, preserveCurrentTrackSelection: true,
@@ -278,8 +278,10 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
startPaused: true, startPaused: true,
reason: 'TV background suspend restore', reason: 'TV background suspend restore',
); );
if (!reloaded) { if (outcome == _MediaReloadOutcome.rejected) {
appLogger.w('TV background suspend restore: in-place reload rejected'); appLogger.w('TV background suspend restore: in-place reload rejected');
} else if (outcome == _MediaReloadOutcome.failed) {
appLogger.w('TV background suspend restore: in-place reload failed');
} }
} }
} }
@@ -175,7 +175,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
// fetchItem populates mediaVersions, so the saved preference resolves to // fetchItem populates mediaVersions, so the saved preference resolves to
// a verified index/id here rather than a raw stored index. // a verified index/id here rather than a raw stored index.
final savedVersion = await resolveSavedMediaVersionFor(metadata); final savedVersion = await resolveSavedMediaVersionFor(metadata);
final handled = await _reloadMediaInPlace( final outcome = await _reloadMediaInPlace(
metadata: metadata, metadata: metadata,
selectedMediaIndex: savedVersion?.index ?? 0, selectedMediaIndex: savedVersion?.index ?? 0,
selectedMediaSourceId: savedVersion?.sourceId, selectedMediaSourceId: savedVersion?.sourceId,
@@ -187,7 +187,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
reason: 'watch together media switch', reason: 'watch together media switch',
); );
if (!mounted) return false; if (!mounted) return false;
if (!handled) { if (outcome == _MediaReloadOutcome.rejected) {
if (player == null) { if (player == null) {
unawaited(_replaceScreenWithPlayer(metadata)); unawaited(_replaceScreenWithPlayer(metadata));
return true; return true;
@@ -196,8 +196,8 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState {
// error; the next heartbeat re-dispatches and converges once idle. // error; the next heartbeat re-dispatches and converges once idle.
return false; return false;
} }
// handled==true also covers "reload failed after rollback" and // failed (after rollback) and superseded land here too — trust only the
// "superseded by a newer attempt" — trust only the committed identity. // committed identity.
final onTarget = _currentMetadata.id == ratingKey && _currentMetadata.serverId == serverId; final onTarget = _currentMetadata.id == ratingKey && _currentMetadata.serverId == serverId;
if (onTarget) { if (onTarget) {
// A success ends the failure episode for this key; a later failure to // A success ends the failure episode for this key; a later failure to
+21
View File
@@ -134,6 +134,27 @@ Future<void> _setWakelock(bool enabled) async {
/// transition is in flight. /// transition is in flight.
enum _PlaybackTransition { idle, reloadingMedia, restartingTranscode, switchingChannel } enum _PlaybackTransition { idle, reloadingMedia, restartingTranscode, switchingChannel }
/// Outcome of [VideoPlayerScreenState._reloadMediaInPlace].
enum _MediaReloadOutcome {
/// An entry guard refused the attempt (live screen, unmounted, another
/// transition in flight). Nothing was touched; safe to retry later.
rejected,
/// A newer playback attempt took ownership mid-reload; its outcome
/// governs what is on screen now.
superseded,
/// The replacement media opened and its session committed. A post-open
/// step may still have failed (tracks/services were rewired in the
/// catch), but the network stream is fresh.
opened,
/// The reload failed before the replacement opened: the previous session
/// is still committed, the eagerly-set identity was rolled back, and the
/// old (possibly dead — #1520) stream is still loaded.
failed,
}
/// Handle for one playback attempt (initial start, in-place reload, /// Handle for one playback attempt (initial start, in-place reload,
/// transcode restart). Async continuations check [isCurrent] after every /// transcode restart). Async continuations check [isCurrent] after every
/// await: it holds while the screen is mounted, the captured player is /// await: it holds while the screen is mounted, the captured player is