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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user