Files
plezy/lib/screens/video_player/parts/episode_navigation.dart
T
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00

932 lines
41 KiB
Dart

part of '../../video_player_screen.dart';
/// Keeps an explicit transcode subtitle choice pending while the native
/// player finishes discovering sidecars that were attached during open.
///
/// Returning true tells the source-switch caller that no media reload is
/// needed. [TrackManager] owns the generation-scoped late-track listener.
Future<bool> deferTranscodeSubtitleSelection({
required TrackManager trackManager,
required MediaSubtitleTrack sourceTrack,
required PlaybackSubtitleSidecar sourceSidecar,
required int sourceStreamId,
required Future<void> Function(SubtitleTrack track, {int? sourceStreamId}) onSubtitleTrackChanged,
required bool Function() shouldContinue,
}) async {
final deferredTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sourceSidecar);
trackManager.preferredSubtitleTrack = SubtitlePreference.track(deferredTrack);
// Persist first: the screen callback routes to onSubtitleTrackSelectedByUser,
// which invalidates the pending selection. Arming before that would retire the
// deferred pass we depend on to apply this choice once mpv discovers the sidecar.
await onSubtitleTrackChanged(deferredTrack, sourceStreamId: sourceStreamId);
// Persisting suspends, so the switch may have been superseded meanwhile.
// Arming then would attach a listener belonging to an operation nobody is
// waiting on any more.
if (!shouldContinue()) return false;
trackManager.applyTrackSelectionWhenReady();
return true;
}
extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
void _clearEpisodeLoadingFlags() {
if (!_isLoadingNext && !_isLoadingPrevious) return;
_setPlayerState(() {
_isLoadingNext = false;
_isLoadingPrevious = false;
});
}
/// Old screen-swap parity: after an in-place item change (or its failed
/// rollback), surface the chrome and re-anchor focus on play/pause. The
/// control that drove the swap (next button, queue item, play-next prompt)
/// may have unmounted or unfocused by now — without a fresh route's
/// autofocus, dpad navigation would be stranded until the chrome is hidden
/// and re-shown. Focusing play/pause is invisible in pointer mode (focus
/// visuals are keyboard/dpad-gated).
void _showChromeForSwappedItem() {
if (!mounted) return;
_chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause);
}
Future<void> _playNext() async {
if (!_canNavigateMediaItems()) return;
if (!mounted) return;
if (_nextEpisode == null || _isLoadingNext) return;
_autoPlayTimer?.cancel();
_unfocusPlayNextPrompt();
_dismissStillWatching();
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
_setPlayerState(() {
_isLoadingNext = true;
_showPlayNextDialog = false;
});
await _navigateToEpisode(_nextEpisode!);
}
Future<void> _playPrevious() async {
if (!_canNavigateMediaItems()) return;
if (_previousEpisode == null || _isLoadingPrevious) return;
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
_setPlayerState(() {
_isLoadingPrevious = true;
});
await _navigateToEpisode(_previousEpisode!);
}
Future<void> _restartOrPlayPrevious() async {
if (!_canNavigateMediaItems()) return;
final currentPlayer = player;
if (!mounted || currentPlayer == null || _isLoadingPrevious) return;
if (!shouldRestartBeforePreviousItem(currentPlayer.state.position) && _previousEpisode != null) {
await _playPrevious();
return;
}
_autoPlayTimer?.cancel();
_unfocusPlayNextPrompt();
_dismissStillWatching();
_setPlayerState(() {
_showPlayNextDialog = false;
_completionLatch.reset();
});
final target = clampSeekPosition(currentPlayer, Duration.zero);
await _seekPlayback(target);
if (!mounted || currentPlayer != player) return;
_notifyWatchTogetherSeek(target);
_updateMediaControlsPlaybackState();
}
/// Replace this screen with a fresh player route — the fallback for flows
/// the in-place reload cannot serve. Marks the screen as being replaced so
/// dispose skips the app-level player-exit side effects the replacement
/// route takes over (WT host-exit notify, sleep timer, system UI restore,
/// display mode).
Future<void> _replaceScreenWithPlayer(MediaItem metadata) async {
_isReplacingWithVideo = true; // before any await — dispose can run mid-helper
try {
await navigateToVideoPlayer(
context,
metadata: metadata,
usePushReplacement: true,
isOffline: _offlineLibraryMode,
);
} finally {
// Still mounted ⇒ no push happened (external-player branch or a
// throw): this screen stays, so restore normal-exit semantics.
if (mounted) {
_isReplacingWithVideo = false;
_clearEpisodeLoadingFlags();
}
}
}
/// Navigates to a new episode by reusing the current player whenever possible.
Future<void> _navigateToEpisode(MediaItem episodeMetadata) async {
final currentPlayer = player;
if (currentPlayer == null) {
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
return;
}
// Carry the playing version to the next episode by signature — its Media
// list may order versions differently, so the bare index is a guess and
// the source id is per-episode.
final currentVersionSignature =
_effectiveSelectedMediaIndex >= 0 && _effectiveSelectedMediaIndex < _availableVersions.length
? _availableVersions[_effectiveSelectedMediaIndex].signature
: null;
// Users who curate per-episode selections server-side (e.g. via Plex Auto
// Languages) opt out of carrying tracks across episodes entirely: with no
// preferences, both audio and subtitles start at the server-selected
// priority (#1717).
final settingsService = await SettingsService.getInstance();
final followServerSelections = settingsService.read(SettingsService.followServerTrackSelections);
final committedSubtitleSelection = _playbackSession?.subtitleSelection;
final primarySubtitlePreference = followServerSelections
? null
: subtitlePreferenceForItemChange(
hasCommittedSelection: committedSubtitleSelection != null,
committedTrack: committedSubtitleSelection?.primaryTrack,
nativeTrack: currentPlayer.state.track.subtitle,
);
final secondarySubtitlePreference = followServerSelections
? null
: subtitlePreferenceForItemChange(
hasCommittedSelection: committedSubtitleSelection != null,
committedTrack: committedSubtitleSelection?.secondaryTrack,
nativeTrack: currentPlayer.state.track.secondarySubtitle,
);
await _reloadMediaInPlace(
metadata: episodeMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: null,
preferredVersionSignature: currentVersionSignature,
qualityPreset: _selectedQualityPreset,
// Stream ids are per-part: the previous episode's audio id is
// meaningless on the new item, so let preferences pick the track.
useCurrentAudioStreamSelection: false,
preserveCurrentTrackSelection: !followServerSelections,
preservedSubtitleTrack: primarySubtitlePreference,
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
reason: 'episode navigation',
);
}
Future<PlaybackSourceChangeOutcome> _switchPlaybackSource({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
}) async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return PlaybackSourceChangeOutcome.unavailable;
if (widget.isLive) return PlaybackSourceChangeOutcome.unavailable;
final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingSource);
if (transitionLease == null) return PlaybackSourceChangeOutcome.busy;
try {
return await _performPlaybackSourceSwitch(
currentPlayer: currentPlayer,
transitionLease: transitionLease,
newMediaIndex: newMediaIndex,
newPreset: newPreset,
newAudioStreamId: newAudioStreamId,
newSubtitleChoice: newSubtitleChoice,
);
} finally {
_releasePlaybackTransition(transitionLease);
}
}
Future<PlaybackSourceChangeOutcome> _performPlaybackSourceSwitch({
required Player currentPlayer,
required _PlaybackTransitionLease transitionLease,
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
}) async {
bool isCurrentSourceSwitch() =>
mounted &&
player == currentPlayer &&
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource);
bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded;
// Snapshot the backend client before subtitle selection can cross an
// async boundary or the profile-scoped context can disappear.
final serverId = _currentMetadata.serverId;
final isPlexBacked = _currentMetadata.backend == MediaBackend.plex;
PlexClient? streamSelectClient;
if (isPlexBacked && serverId != null) {
try {
streamSelectClient = context.getPlexClientForServer(ServerId(serverId));
} catch (_) {}
}
if (newSubtitleChoice != null && newMediaIndex == null && newPreset == null && newAudioStreamId == null) {
try {
final selected = await _selectSourceSubtitleLocally(
currentPlayer,
newSubtitleChoice,
shouldContinue: isCurrentSourceSwitch,
);
if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded;
if (selected) {
return PlaybackSourceChangeOutcome.applied;
}
} catch (e) {
if (sourceSwitchWasSuperseded()) return PlaybackSourceChangeOutcome.superseded;
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
return PlaybackSourceChangeOutcome.failed;
}
}
final effectiveMediaIndex = newMediaIndex ?? _effectiveSelectedMediaIndex;
final effectivePreset = newPreset ?? _selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls());
final preferredSubtitleTrackForReload = newSubtitleChoice == null
? SubtitlePreference.trackOrNull(_playbackSession?.subtitleSelection.primaryTrack)
: newSubtitleChoice.isOff
? const SubtitlePreference.off()
: SubtitlePreference.trackOrNull(
PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!),
);
final effectiveMediaSourceId = newMediaIndex != null
? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId
: _requestedMediaSourceId;
final isVersionChange =
effectiveMediaIndex != _effectiveSelectedMediaIndex ||
(_requestedMediaSourceId != null && effectiveMediaSourceId != _requestedMediaSourceId);
final isPresetChange = effectivePreset != _selectedQualityPreset;
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
return PlaybackSourceChangeOutcome.unchanged;
}
try {
if (isVersionChange) {
await saveMediaVersionPreferenceFor(_currentMetadata, index: effectiveMediaIndex, versions: _availableVersions);
if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded;
}
if ((isSubtitleChange && isPlexBacked) || (isAudioChange && isPlexBacked)) {
final partId = _currentMediaInfo?.partId;
if (streamSelectClient == null || partId == null) {
throw StateError('No Plex part available for stream selection');
}
final saved = await streamSelectClient.selectStreams(
partId,
audioStreamID: isAudioChange ? effectiveAudioStreamId : null,
// Plex's wire API uses 0 for Off. Keep that convention at this
// backend boundary so it cannot collide with Jellyfin source ids.
subtitleStreamID: isSubtitleChange
? newSubtitleChoice.isOff
? 0
: newSubtitleChoice.sourceStreamId
: null,
allParts: true,
);
if (!saved) {
throw StateError('Failed to select streams');
}
if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded;
}
final outcome = await _reloadMediaInPlace(
metadata: _currentMetadata.copyWith(viewOffsetMs: currentPlayer.state.position.inMilliseconds),
selectedMediaIndex: effectiveMediaIndex,
selectedMediaSourceId: effectiveMediaSourceId,
qualityPreset: effectivePreset,
// A version change selects a different part, and stream ids are
// per-part — only same-part switches may carry the current id.
selectedAudioStreamId: isVersionChange ? newAudioStreamId : effectiveAudioStreamId,
useCurrentAudioStreamSelection: !isVersionChange,
resumePosition: currentPlayer.state.position,
preserveCurrentTrackSelection: false,
preferredSubtitleTrackOverride: preferredSubtitleTrackForReload,
transitionLease: transitionLease,
reason: 'source switch',
);
return switch (outcome) {
_MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied,
_MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy,
_MediaReloadOutcome.superseded => PlaybackSourceChangeOutcome.superseded,
_MediaReloadOutcome.failed => PlaybackSourceChangeOutcome.failed,
};
} catch (e) {
// A normal switchingSource -> reloadingMedia phase advance (and the
// reload's eventual release) does not mean this operation was replaced.
// Only an explicit force-idle/new playback generation supersedes the
// lease; real errors after an owned phase change must remain failures.
if (sourceSwitchWasSuperseded()) return PlaybackSourceChangeOutcome.superseded;
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
return PlaybackSourceChangeOutcome.failed;
}
}
Future<bool> _selectSourceSubtitleLocally(
Player currentPlayer,
PlaybackSourceSubtitleChoice choice, {
required bool Function() shouldContinue,
}) async {
if (choice.isOff) {
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
if (!shouldContinue()) return false;
_onSecondarySubtitleTrackChanged(SubtitleTrack.off);
await currentPlayer.selectSubtitleTrack(SubtitleTrack.off);
if (!shouldContinue()) return false;
await _onSubtitleTrackChanged(SubtitleTrack.off);
return true;
}
final sourceStreamId = choice.sourceStreamId!;
final info = _currentMediaInfo;
if (info == null) return false;
MediaSubtitleTrack? sourceTrack;
for (final candidate in info.subtitleTracks) {
if (candidate.id == sourceStreamId) {
sourceTrack = candidate;
break;
}
}
if (sourceTrack == null) return false;
final nativeTracks = currentPlayer.state.tracks.subtitle;
final session = _playbackSession;
final sourceSidecar = session == null ? null : _sidecarForSourceStreamId(session, sourceStreamId);
final nativeTrack = PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: sourceTrack,
nativeTracks: nativeTracks,
allSourceTracks: info.subtitleTracks,
isResolvedSidecar: sourceSidecar != null,
isContainerSidecar: sourceSidecar?.track.isContainer == true,
currentSourceStreamId: session?.subtitleSelection.primarySourceStreamId,
selectedNativeTrack: currentPlayer.state.track.subtitle,
);
if (nativeTrack == null) {
final trackManager = _trackManager;
if (!_isTranscoding || sourceSidecar == null || trackManager == null) return false;
return deferTranscodeSubtitleSelection(
trackManager: trackManager,
sourceTrack: sourceTrack,
sourceSidecar: sourceSidecar,
sourceStreamId: sourceStreamId,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
shouldContinue: shouldContinue,
);
}
await currentPlayer.selectSubtitleTrack(nativeTrack);
if (!shouldContinue()) return false;
await _onSubtitleTrackChanged(nativeTrack, sourceStreamId: sourceStreamId);
return true;
}
/// Reload a VOD item/source while keeping the route, player instance, and
/// native renderer alive. This is the common path for episode navigation,
/// queue item jumps, Watch Together media switches, and source changes.
///
/// [preservedAudioTrack]/[preservedSubtitleTrack]/
/// [preservedSecondarySubtitleTrack] override the live player state when
/// [preserveCurrentTrackSelection] is set — for callers whose player no
/// longer holds the selections (the TV background suspend stops the native
/// player, which clears its track state, before the reload runs).
///
/// [startPaused] keeps the reloaded item paused: open() starts held, and
/// every post-open resume point (subtitle-load resume, frame-rate gate
/// release) arms track selection without playing, the same way a Watch
/// Together-owned start does. The caller owns starting playback.
///
/// 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,
int? selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
TranscodeQualityPreset? qualityPreset,
int? selectedAudioStreamId,
Duration? resumePosition,
bool preserveCurrentTrackSelection = false,
AudioTrack? preservedAudioTrack,
SubtitlePreference? preservedSubtitleTrack,
SubtitlePreference? preservedSecondarySubtitleTrack,
SubtitlePreference? preferredSubtitleTrackOverride,
bool startPaused = false,
bool useCurrentAudioStreamSelection = true,
bool showErrorUi = true,
_PlaybackTransitionLease? transitionLease,
String reason = 'media reload',
}) async {
if (widget.isLive) {
_clearEpisodeLoadingFlags();
return _MediaReloadOutcome.rejected;
}
final existingPlayer = player;
if (!mounted || existingPlayer == null) {
if (mounted) _clearEpisodeLoadingFlags();
return _MediaReloadOutcome.rejected;
}
final reloadLease = transitionLease == null
? _tryAcquirePlaybackTransition(_PlaybackTransition.reloadingMedia)
: _advancePlaybackTransition(
transitionLease,
_PlaybackTransition.reloadingMedia,
expected: _PlaybackTransition.switchingSource,
)
? transitionLease
: null;
if (reloadLease == null) {
_clearEpisodeLoadingFlags();
return _MediaReloadOutcome.rejected;
}
try {
final currentPlayer = existingPlayer;
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true);
bool isCurrentReload() => attempt.isCurrent && !_hasFatalPlaybackError && !_isExiting.value;
// The session itself swaps atomically at the open boundary, so the only
// rollback state is the eagerly-set identity (shown by the loading UI)
// and the first-frame flag.
final previousMetadata = _currentMetadata;
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
final previousPartId = _currentMediaInfo?.partId;
final previousMediaSourceId = _currentMediaInfo?.mediaSourceId;
final previousHasFirstFrame = _hasFirstFrame.value;
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
final previousHasFatalPlaybackError = _hasFatalPlaybackError;
_hasFatalPlaybackError = false;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
final currentAudioTrack = preserveCurrentTrackSelection
? preservedAudioTrack ?? currentPlayer.state.track.audio
: null;
final currentSubtitleTrack =
preferredSubtitleTrackOverride ??
(preserveCurrentTrackSelection
? preservedSubtitleTrack ?? SubtitlePreference.trackOrNull(currentPlayer.state.track.subtitle)
: null);
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? preservedSecondarySubtitleTrack ??
SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle)
: null;
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
var didOpenReplacement = false;
// Capture context-dependent values before async gaps. The neutral
// [PlaybackInitializationService] consumes [mediaClient] regardless of
// backend. We still narrow to [plexClient] for [TrackManager]'s
// server-side track persistence, which is Plex-only — Jellyfin
// sessions get a null `getPlexClient` and skip that path.
late final OfflineWatchSyncService offlineWatchService;
late final UserProfileProvider userProfileProvider;
late final PlaybackStateProvider playbackState;
late final AppDatabase database;
late final MultiServerManager serverManager;
late final WatchTogetherProvider? watchTogether;
late final bool watchTogetherWasAttached;
late final bool cycleWatchTogetherAttachment;
late final bool wtOwnsStart;
try {
offlineWatchService = context.read<OfflineWatchSyncService>();
userProfileProvider = context.read<UserProfileProvider>();
playbackState = context.read<PlaybackStateProvider>();
database = context.read<AppDatabase>();
serverManager = context.read<MultiServerProvider>().serverManager;
// Cycle the Watch Together attachment across every reload: the
// reload's internal pause/open churn must not leak into the sync layer
// as user intents. Readiness re-handshakes on re-attach (item changes
// start a new media epoch; same-item source switches group-wait while
// we reload).
watchTogether = _activeWatchTogetherSession();
watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false;
cycleWatchTogetherAttachment = watchTogetherWasAttached;
wtOwnsStart = _watchTogetherOwnsPlaybackStart();
} catch (e, stackTrace) {
appLogger.e('Failed to prepare media reload during $reason', error: e, stackTrace: stackTrace);
if (mounted && showErrorUi) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
_clearEpisodeLoadingFlags();
return _MediaReloadOutcome.failed;
}
final shouldAutoStart = shouldAutoStartReloadedMedia(
wasPlayingBeforeReload: wasPlayingBeforeReload,
watchTogetherOwnsStart: wtOwnsStart,
startPaused: startPaused,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex;
final targetQualityPreset = qualityPreset ?? _selectedQualityPreset;
final targetAudioStreamId = useCurrentAudioStreamSelection
? selectedAudioStreamId ?? _selectedAudioStreamId
: selectedAudioStreamId;
final targetLaunchIdentity = VideoPlayerLaunchIdentity(
metadata: metadata,
mediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: targetQualityPreset,
isOffline: _offlineLibraryMode,
routeKind: VideoPlayerRouteKind.vod,
);
final preservesRequestedSubtitleSource =
!isItemChange &&
targetMediaIndex == _effectiveSelectedMediaIndex &&
(selectedMediaSourceId == null || selectedMediaSourceId == previousMediaSourceId);
final initializationSubtitleTrack = preservesRequestedSubtitleSource
? currentSubtitleTrack
: SubtitlePreference.demoteToIntent(currentSubtitleTrack);
try {
// Eager identity-only: the loading UI shows the new title immediately,
// while the selection/source state flips with the session commit at
// the open boundary. Keep these writes inside the rollback boundary.
_currentMetadata = metadata;
VideoPlayerScreenState._activeRouteGuard.update(this, targetLaunchIdentity);
_unfocusPlayNextPrompt();
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
_hasFirstFrame.value = false;
// Detach before pausing so the reload's internal pause can't broadcast
// a party-wide pause; the finally below restores the attachment.
if (cycleWatchTogetherAttachment) {
watchTogether!.detachPlayer();
}
try {
await currentPlayer.pause();
} catch (e) {
appLogger.w('Failed to pause before $reason', error: e);
}
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Overlap the old item's stop report with the resolve round-trip; it
// is awaited again right before the open below.
final stoppedProgressFuture = _sendStoppedProgressOnce();
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
final playbackContext = await playbackResolver.resolve(
PlaybackInitializationOptions(
metadata: metadata,
selectedMediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
preferredSubtitleTrack: initializationSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: _offlineLibraryMode,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final result = playbackContext.result;
final mediaClient = playbackContext.reportingClient;
final plexClient = mediaClient is PlexClient ? mediaClient : null;
final streamHeaders = playbackContext.streamHeaders;
if (result.videoUrl == null) {
throw PlaybackException('No video URL available');
}
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: metadata,
result: result,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
preserveSubtitleSourceIdentity:
result.mediaInfo != null &&
((previousMediaSourceId != null && previousMediaSourceId == result.mediaInfo!.mediaSourceId) ||
(previousPartId != null && previousPartId == result.mediaInfo!.partId)),
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Build the replacement session now, commit it only once open()
// succeeds — until then every session-derived getter still describes
// the item that is actually playing.
var session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: targetQualityPreset,
requestedMediaSourceId: selectedMediaSourceId,
subtitleSelection: subtitleSelection,
);
if (result.fallbackReason != null && !targetQualityPreset.isOriginal && mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
final openResumePosition = await _resolveOpenResumePosition(
metadata: metadata,
isOffline: _offlineLibraryMode || result.isOffline,
offlineWatchService: offlineWatchService,
requested: resumePosition,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final displayCriteria = result.mediaInfo?.displayCriteria;
final settingsService = await SettingsService.getInstance();
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same pre-open frame-rate orchestration as the initial start flow —
// including the Android MPV startup decoder refresh, whose gate is
// armed before open and released after track setup below.
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: true,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
);
if (frameRatePlan == null || !isCurrentReload()) {
return _MediaReloadOutcome.superseded;
}
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) _frameRate.applied = true;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final openTiming = _playbackOpenTiming(
isTranscoding: result.isTranscoding,
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
);
await stoppedProgressFuture;
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Generation invalidation prevents follow-on selection calls, but a
// native audio/subtitle/rate mutation may already have been
// dispatched. Drain exactly that captured operation before reusing
// the player for replacement media, otherwise its late completion can
// mutate the replacement item's tracks.
await attempt.trackMutationDrain;
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
// Not _isOfflinePlayback: the replacement session commits later, in
// onOpened, so the getter still describes the previous item here.
isLocalMedia: _offlineLibraryMode || result.usesLocalMedia,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders,
play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
onOpening: () {
_hasRenderedFirstFrame = false;
},
onOpened: () {
// The player now owns the new file — publish the session at the
// same boundary so identity and source state flip together.
didOpenReplacement = true;
_commitPlaybackSession(session);
},
);
// A false didOpen means shouldContinue stopped the sequence pre-open
// (open failures throw into the catch below) — superseded either way.
if (!openResult.didOpen || !isCurrentReload()) {
return _MediaReloadOutcome.superseded;
}
if (openResult.sidecarFallbackUsed) {
session = _commitSidecarFallbackSession(session);
subtitleSelection = session.subtitleSelection;
effectiveExternalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: const [],
);
}
_completionLatch.reset();
if (isItemChange) {
// Same-item reloads (including the spurious-EOF recovery itself and
// quality switches) keep the spent budget — that is the loop guard.
_spuriousEofRecoveryAttempts = 0;
_spuriousEofRecoveryBaselineMs = null;
}
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up. Same-part switches (quality/audio/subtitle)
// keep the scrub-preview source — BIF/trickplay is per part, so a
// reset would re-download identical bytes.
final reusesScrubPreview =
previousMetadata.globalKey == metadata.globalKey &&
previousPartId != null &&
previousPartId == result.mediaInfo?.partId;
if (reusesScrubPreview) {
_setPlayerState(() {});
} else {
_resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
}
_clearEpisodeLoadingFlags();
if (isItemChange) _showChromeForSwappedItem();
_trackManager?.dispose();
final trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: metadata,
plexClient: plexClient,
getProfileSettings: () => userProfileProvider.profileSettings,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
);
_trackManager = trackManager;
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
shouldAutoStart && frameRatePlan.needsStartupRefresh && effectiveExternalSubtitlePlan.requiresPostOpenAdd;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
// Same guard as the start path: don't resume a player a newer flow
// owns, and let a pending startup gate (or Watch Together's group
// start) own the resume instead. Post-open external-subtitle paths
// resume once here so the startup refresh gate can observe a frame.
shouldResumeAfterSubtitleLoad: () =>
shouldAutoStart &&
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// Paused reloads use the same no-resume branch as an externally
// coordinated start: track selection is armed without manufacturing
// a new play intent.
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: effectiveExternalSubtitlePlan,
reason: reason,
shouldResume: shouldAutoStart,
watchTogetherOwnsStart: wtOwnsStart,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Same helper as the initial start flow, so any future change lands in
// both paths together.
_wirePerItemPlaybackServices(
metadata: metadata,
mediaClient: mediaClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
playMethod: _playbackPlayMethod,
mediaInfo: _currentMediaInfo,
);
_setPlayerState(() {
_nextEpisode = null;
_previousEpisode = null;
_nextEpisodeStatus = QueueNavigationStatus.failed;
});
try {
playbackState.setCurrentItem(metadata);
} catch (e) {
appLogger.d('playbackState.setCurrentItem failed', error: e);
}
unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt));
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
if (_autoPipEnabled) {
unawaited(_updateAutoPipState(isPlaying: currentPlayer.state.playing));
}
return _MediaReloadOutcome.opened;
} catch (e) {
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
_completionLatch.reset();
if (!didOpenReplacement) {
// Nothing was opened: the previous session is still committed, so
// only the eagerly-set identity needs restoring before resuming.
_currentMetadata = previousMetadata;
if (previousLaunchIdentity != null) {
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
}
_hasFirstFrame.value = previousHasFirstFrame;
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
_hasFatalPlaybackError = previousHasFatalPlaybackError;
// If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends).
_progressTracker?.resumeAfterStoppedReport();
if (wasPlayingBeforeReload && mounted && player == currentPlayer) {
unawaited(_playWithPlaybackIntent(currentPlayer));
}
} else if (_progressTracker == null && player == currentPlayer) {
// The new file is playing and its session is committed — keep the
// new identity and make sure progress reporting is wired to the
// item actually on screen (the failure may have hit before
// _wirePerItemPlaybackServices ran).
_wirePerItemPlaybackServices(
metadata: metadata,
mediaClient: _playbackSession?.reportingClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
playMethod: _playbackPlayMethod,
mediaInfo: _currentMediaInfo,
);
}
// Unconditional setState — beyond the flags this also publishes the
// rolled-back identity (_clearEpisodeLoadingFlags skips the rebuild
// when no loading flags are set).
_setPlayerState(() {
_isLoadingNext = false;
_isLoadingPrevious = false;
});
if (isItemChange) _showChromeForSwappedItem();
appLogger.e('Failed to reload media in-place during $reason', error: e);
if (mounted && showErrorUi) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
return didOpenReplacement ? _MediaReloadOutcome.opened : _MediaReloadOutcome.failed;
} finally {
// Restore Watch Together sync on every exit: after a successful item
// change (readiness re-handshakes for the new item), after a failed
// reload (the still-playing old item must stay synced), and when the
// controller auto-detached itself on a mid-reload player failure.
// _currentMetadata is correct on both the success and rollback paths
// by the time we get here.
try {
final reattachServerId = _currentMetadata.serverId;
if (watchTogetherWasAttached &&
watchTogether != null &&
watchTogether.isInSession &&
mounted &&
player == currentPlayer &&
reattachServerId != null &&
!watchTogether.hasAttachedPlayer) {
watchTogether.attachPlayer(
currentPlayer,
ratingKey: _currentMetadata.id,
serverId: reattachServerId,
mediaTitle: _currentMetadata.displayTitle,
hasFirstFrame: _hasFirstFrame.value,
remoteSeek: _seekPlayback,
);
}
} catch (e, stackTrace) {
// Playback has already reached a definitive opened/failed outcome;
// a best-effort sync reattach must not rewrite it or escape through
// the source-switch error classifier.
appLogger.w('Failed to reattach Watch Together after $reason', error: e, stackTrace: stackTrace);
}
}
} finally {
// Cover setup as well as async playback work: context/provider reads can
// throw before the operational try/catch is entered. Identity ownership
// prevents this continuation from releasing a newer transition.
_releasePlaybackTransition(reloadLease);
}
}
}