Files
plezy/lib/screens/video_player/parts/episode_navigation.dart
T
edde746 c885c0c6bc fix(player): intercept spurious mid-file EOF and recover in place
close #1520

Classify player EOF signals against the best-known duration: a mid-file
EOF means the stream died (transcode reaped or idle connection closed
during a long pause), not that the media ended — it must never mark the
item watched, prompt Play Next, or exit a movie. Recover with a bounded
in-place reload at the parked position; if the server is still refusing,
park on the old frame and rebuild the stream on user play/seek or when
the server-status monitor sees it come back online.
2026-07-10 19:11:18 +02:00

626 lines
27 KiB
Dart

part of '../../video_player_screen.dart';
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 (!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 (_previousEpisode == null || _isLoadingPrevious) return;
_notifyWatchTogetherMediaChange(metadata: _previousEpisode);
_setPlayerState(() {
_isLoadingPrevious = true;
});
await _navigateToEpisode(_previousEpisode!);
}
Future<void> _restartOrPlayPrevious() async {
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 {
if (player == 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;
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: true,
reason: 'episode navigation',
);
}
Future<void> _switchPlaybackSource({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
}) async {
final currentPlayer = player;
if (!mounted || currentPlayer == null || _playbackTransition != _PlaybackTransition.idle) return;
final effectiveMediaIndex = newMediaIndex ?? _effectiveSelectedMediaIndex;
final effectivePreset = newPreset ?? _selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId;
final currentSubtitleStreamId = _selectedSourceSubtitleStreamIdForControls(_sourceSubtitleTracksForControls());
final effectiveSubtitleStreamId = newSubtitleStreamId ?? currentSubtitleStreamId;
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 = newSubtitleStreamId != null && effectiveSubtitleStreamId != currentSubtitleStreamId;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) return;
// Read the client before any await — context across an async gap. A
// missing client leaves this null and the guard below reports it.
final serverId = _currentMetadata.serverId;
final isPlexBacked = _currentMetadata.backend == MediaBackend.plex;
PlexClient? streamSelectClient;
if ((isSubtitleChange || (isAudioChange && isPlexBacked)) && serverId != null) {
try {
streamSelectClient = context.getPlexClientForServer(ServerId(serverId));
} catch (_) {}
}
try {
if (isVersionChange) {
await saveMediaVersionPreferenceFor(_currentMetadata, index: effectiveMediaIndex, versions: _availableVersions);
}
if (isSubtitleChange || (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,
subtitleStreamID: isSubtitleChange ? effectiveSubtitleStreamId : null,
allParts: true,
);
if (!saved) {
throw StateError('Failed to select streams');
}
}
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,
reason: 'source switch',
);
} catch (e) {
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
/// 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,
SubtitleTrack? preservedSubtitleTrack,
SubtitleTrack? preservedSecondarySubtitleTrack,
bool startPaused = false,
bool useCurrentAudioStreamSelection = true,
bool showErrorUi = true,
String reason = 'media reload',
}) async {
if (widget.isLive) {
_clearEpisodeLoadingFlags();
return _MediaReloadOutcome.rejected;
}
final existingPlayer = player;
if (!mounted || existingPlayer == null || _playbackTransition != _PlaybackTransition.idle) {
if (mounted) _clearEpisodeLoadingFlags();
return _MediaReloadOutcome.rejected;
}
_playbackTransition = _PlaybackTransition.reloadingMedia;
final currentPlayer = player!;
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true);
bool isCurrentReload() => attempt.isCurrent;
// 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 previousMediaIndex = _effectiveSelectedMediaIndex;
final previousPartId = _currentMediaInfo?.partId;
final previousHasFirstFrame = _hasFirstFrame.value;
final isItemChange = previousMetadata.globalKey != metadata.globalKey;
final currentAudioTrack = preserveCurrentTrackSelection
? preservedAudioTrack ?? currentPlayer.state.track.audio
: null;
final currentSubtitleTrack = preserveCurrentTrackSelection
? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle
: null;
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? preservedSecondarySubtitleTrack ?? 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.
final offlineWatchService = context.read<OfflineWatchSyncService>();
final userProfileProvider = context.read<UserProfileProvider>();
final playbackState = context.read<PlaybackStateProvider>();
final database = context.read<AppDatabase>();
final 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).
final watchTogether = _activeWatchTogetherSession();
final watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false;
final cycleWatchTogetherAttachment = watchTogetherWasAttached;
final wtOwnsStart = _watchTogetherOwnsPlaybackStart();
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex;
final targetQualityPreset = qualityPreset ?? _selectedQualityPreset;
final targetAudioStreamId = useCurrentAudioStreamSelection
? selectedAudioStreamId ?? _selectedAudioStreamId
: selectedAudioStreamId;
// 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.
_currentMetadata = metadata;
VideoPlayerScreenState._activeId = metadata.id;
VideoPlayerScreenState._activeMediaIndex = targetMediaIndex;
_unfocusPlayNextPrompt();
_showPlayNextDialog = false;
_autoPlayTimer?.cancel();
_hasFirstFrame.value = false;
try {
// 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(
metadata: metadata,
selectedMediaIndex: targetMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
offlineLibraryMode: _offlineLibraryMode,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
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');
}
// 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.
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: targetQualityPreset,
requestedMediaSourceId: selectedMediaSourceId,
);
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(
backend: metadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
);
await stoppedProgressFuture;
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
frameRatePlan.armStartupRefreshGate(currentPlayer);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
);
final didOpen = 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:
!frameRatePlan.holdPlaybackStart &&
!wtOwnsStart &&
!startPaused &&
externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload,
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 (!didOpen || !isCurrentReload()) return _MediaReloadOutcome.superseded;
_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: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
);
_trackManager = trackManager;
trackManager.cacheExternalSubtitles(result.externalSubtitles);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart && !startPaused;
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: externalSubtitlePlan,
// 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: () =>
(!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) &&
!wtOwnsStart &&
!startPaused &&
mounted &&
player == currentPlayer,
applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart,
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
// startPaused rides the Watch Together yield path: the gate release
// arms track selection but leaves the player paused for the caller.
resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
wtOwnsStart: wtOwnsStart || startPaused,
),
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,
);
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(_videoPIPManager?.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;
VideoPlayerScreenState._activeId = previousMetadata.id;
VideoPlayerScreenState._activeMediaIndex = previousMediaIndex;
_hasFirstFrame.value = previousHasFirstFrame;
// 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 {
// Release the reload transition unless a newer flow already took
// ownership (a non-reload attempt force-idles it; a newer reload can
// then re-acquire it).
if (attempt.isCurrent && _playbackTransition == _PlaybackTransition.reloadingMedia) {
_playbackTransition = _PlaybackTransition.idle;
}
// 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.
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,
);
}
}
}
}