diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 2c9050ccf..69c58cf93 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -735,7 +735,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; } - var subtitleSelection = await _resolveSubtitleSelectionForOpen( + final subtitleSelection = await _resolveSubtitleSelectionForOpen( metadata: metadata, result: result, preferredAudioTrack: initializationAudioTrack, @@ -751,7 +751,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // 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( + final session = PlaybackSession.fromContext( playbackContext, requestedQualityPreset: targetQualityPreset, requestedMediaSourceId: selectedMediaSourceId, @@ -761,78 +761,85 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); } - 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( + // Shared open orchestration with the initial start flow + // ([_openResolvedMedia]) — including the Android MPV startup decoder + // refresh, whose gate is armed before open and released after track + // setup. + final flow = await _openResolvedMedia( currentPlayer: currentPlayer, settingsService: settingsService, - preKnownFps: displayCriteria?.fps, - preKnownWidth: displayCriteria?.width ?? 0, - preKnownHeight: displayCriteria?.height ?? 0, - hasVideoUrl: true, - isTranscoding: result.isTranscoding, - 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, - ); - _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, + metadata: metadata, + result: result, + session: session, + subtitleSelection: subtitleSelection, + headers: result.usesLocalMedia ? null : streamHeaders, // 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, - // The vehicle is not consulted here: `_openMediaOnPlayer` reads it at the `player.open` - // itself, which is after this and its own awaited tuning work. - play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup, - externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, - shouldContinue: isCurrentReload, + isCurrent: isCurrentReload, + staleGuard: isCurrentReload, + // Captured before the reload detached the player from the sync + // layer; a live read would see the detached state. + watchTogetherOwnsStart: () => wtOwnsStart, + resolveShouldAutoStart: (_) => shouldAutoStart, + resumePosition: () => openResumePosition, + plexClient: () => plexClient, + getProfileSettings: () => userProfileProvider.profileSettings, + preferredAudioTrack: initializationAudioTrack, + primarySubtitleTranscoding: () => result.isTranscoding, + ensureAudioFocus: () => currentPlayer.requestAudioFocus(), + clearFirstFrameForOpen: false, + deferAutomotiveStart: false, + beforeArm: () async { + if (!isCurrentReload()) return false; + _progressTracker?.stopTracking(); + _progressTracker?.dispose(); + _progressTracker = null; + unawaited(DiscordRPCService.instance.stopPlayback()); + unawaited(TrackerCoordinator.instance.stopPlayback()); + if (!isCurrentReload()) return false; + + // 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; + return isCurrentReload(); + }, + afterMediaOpened: (_, _, _) async { + _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(); + return true; + }, onOpening: () { _hasRenderedFirstFrame = false; // 503s observed from here on belong to the replacement open. @@ -845,100 +852,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _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: initializationAudioTrack, - // A declined carry stays alive for the native passes: freezing the - // resolver's off verdict here would turn a metadata mismatch into a - // navigation-priority off that no late track can undo (#1785). - preferredSubtitleTrack: - subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack), - preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack), - // A source-backed primary with no sidecar on a transcode is one the server burned into - // the picture: it is already visible, and no native track will ever arrive to match it. - primarySubtitleIsServerRendered: - result.isTranscoding && - subtitleSelection.primarySourceStreamId != null && - subtitleSelection.primarySidecar == null, - ); - _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 (flow == null) return _MediaReloadOutcome.superseded; if (!isCurrentReload()) return _MediaReloadOutcome.superseded; // Same helper as the initial start flow, so any future change lands in diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index cbf7a9de3..8b58938c5 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -66,12 +66,33 @@ class _MediaOpenResult { final bool sidecarFallbackUsed; } +/// Everything the shared open orchestration ([_openResolvedMedia]) produced +/// that outlives it: the (possibly sidecar-fallback-recomputed) session and +/// subtitle selection, the freshly built per-item track manager, and the +/// plans the open ran under. +class _ResolvedMediaOpenResult { + const _ResolvedMediaOpenResult({ + required this.session, + required this.subtitleSelection, + required this.trackManager, + required this.externalSubtitlePlan, + required this.frameRatePlan, + }); + + final PlaybackSession session; + final PlaybackSubtitleSelection subtitleSelection; + final TrackManager trackManager; + final _ExternalSubtitleOpenPlan externalSubtitlePlan; + final _FrameRateStartupPlan frameRatePlan; +} + /// Shared building blocks for opening media on the live player. /// /// The initial start flow ([_startPlayback]) and in-place reload flow -/// ([_reloadMediaInPlace]) both route through these helpers so per-open -/// behavior (display priming, frame-rate suppression windows, native -/// subtitle styling, and the open sequence) cannot drift between paths. +/// ([_reloadMediaInPlace]) both route through these helpers — and through +/// the shared [_openResolvedMedia] orchestration — so per-open behavior +/// (display priming, frame-rate suppression windows, native subtitle +/// styling, and the open sequence) cannot drift between paths. /// This is also the only place that reads /// [SettingsService.displaySwitchDelay]. extension _VideoPlayerOpenMethods on VideoPlayerScreenState { @@ -713,4 +734,281 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { await _applyNativeSubtitleStyle(player, settingsService); return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed); } + + /// Shared orchestration for opening a resolved source on the live player: + /// pre-open frame-rate negotiation → per-item frame-rate reset → display + /// priming → startup-gate arming → external-subtitle planning → open → + /// sidecar-fallback session recompute → track-manager build → post-open + /// track application → frame-rate startup-gate release. + /// + /// The initial start flow ([_startPlayback]) and the in-place reload flow + /// ([_reloadMediaInPlace]) both run this sequence; caller-specific + /// choreography (session commit boundary, Watch Together attach/detach, + /// progress-tracker teardown, per-screen service setup) stays in the + /// callers and runs through the hooks below at its original position in + /// the sequence. Deliberate per-flow differences are explicit parameters — + /// nothing here may silently unify them. + /// + /// Returns null when a staleness guard or hook aborted the flow (the start + /// flow returns silently, the reload flow maps it to superseded); open + /// failures still throw to the caller. + Future<_ResolvedMediaOpenResult?> _openResolvedMedia({ + required Player currentPlayer, + required SettingsService settingsService, + // start: _currentMetadata; reload: the replacement item's metadata. + required MediaItem metadata, + required PlaybackInitializationResult result, + required PlaybackSession session, + required PlaybackSubtitleSelection subtitleSelection, + // start: the resolved stream headers; reload drops them for local media. + required Map? headers, + // start: _isOfflinePlayback (its session is already committed); reload: + // _offlineLibraryMode || result.usesLocalMedia because its replacement + // session commits later, in [onOpened], so the getter still describes + // the previous item at open time. + required bool isLocalMedia, + // Staleness check used at the shared guard points and as + // [_openMediaOnPlayer]'s shouldContinue. start: attempt.isCurrent; + // reload: isCurrentReload (attempt + fatal-error + exiting). + required bool Function() isCurrent, + // Whether an active Watch Together session owns the (group) start. The + // start flow reads it live right after the frame-rate negotiation (its + // original position); the reload flow captured it before detaching the + // player from the session and replays that value. + required bool Function() watchTogetherOwnsStart, + // Whether this open should end in playing. start: !wtOwnsStart (an + // initial start always intends to play unless the sync layer owns the + // group start); reload: shouldAutoStartReloadedMedia (was-playing / + // startPaused / Watch Together). + required bool Function(bool wtOwnsStart) resolveShouldAutoStart, + // Where playback starts. reload resolves it before the old stop report; + // start resolves it in [beforePrime] (after audio focus, its original + // position) and exposes the value here. + required Duration? Function() resumePosition, + // Plex client for TrackManager's server-side track persistence. reload + // narrows the resolver's reporting client; start derives it from the + // screen's media client inside [beforeTrackSetup]. + required PlexClient? Function() plexClient, + required MediaServerUserProfile? Function() getProfileSettings, + // start: the launch-time preference; reload: the carried-over selection. + required AudioTrack? preferredAudioTrack, + // Transcode signal for the server-rendered-primary-subtitle rule. Known + // per-flow drift, kept deliberately: start reads the live session-backed + // _isTranscoding getter (its session committed before this call); reload + // reads result.isTranscoding because its session commits at the open + // boundary. + required bool Function() primarySubtitleTranscoding, + // Audio-focus hook for the pre-open ExoPlayer switch. start memoizes the + // in-flight _audioFocusFuture; reload requests focus directly. + required Future Function() ensureAudioFocus, + // start: true — the flag is dropped here, right before the frame-rate + // reset; reload dropped it earlier, at its eager-identity boundary. + required bool clearFirstFrameForOpen, + // start: true — open never auto-plays on automotive and [afterMediaOpened] + // re-issues the play intent instead; reload: false — the vehicle verdict + // is read inside [_openMediaOnPlayer] at the player.open itself, which is + // after this call and its own awaited tuning work. + required bool deferAutomotiveStart, + // reload-only extra staleness re-checks (right after the frame-rate plan + // and right after track application); the start flow has none there. + bool Function()? staleGuard, + // start-only: audio focus + resume-position resolution between the + // frame-rate reset and display priming. Return false to abort. + Future Function()? beforePrime, + // reload-only: progress-tracker teardown and the captured track-mutation + // drain between display priming and startup-gate arming. Return false to + // abort. + Future Function()? beforeArm, + // Runs right after the open boundary (incl. the sidecar-fallback session + // recompute), only when a video URL was opened. start: Watch Together + // attach + deferred automotive start; reload: completion-latch/scrub/ + // loading-flag upkeep and disposing the previous track manager. Return + // false to abort. + required Future Function(bool shouldAutoPlay, bool holdPlaybackStart, bool wtOwnsStart) afterMediaOpened, + // start-only: mounted gate + per-screen service setup (scrub preview, + // video filter/PiP, shaders) between the open branch and the track + // manager build. Return false to abort. + Future Function()? beforeTrackSetup, + // start-only: reads the Watch Together startup hold its + // [afterMediaOpened] may have created, consumed when the startup gate + // resolves the post-gate playback decision. + Completer? Function()? wtStartupHold, + // Open-boundary callbacks passed through to [_openMediaOnPlayer]: reload + // disarms the 503 watchdog in [onOpening] and commits its session in + // [onOpened]; start tracks primary-media availability for its error + // classification. + void Function()? onOpening, + void Function()? onOpened, + void Function(bool available)? onMediaAvailabilityChanged, + }) async { + final displayCriteria = result.mediaInfo?.displayCriteria; + + final frameRatePlan = await _prepareFrameRateForOpen( + currentPlayer: currentPlayer, + settingsService: settingsService, + preKnownFps: displayCriteria?.fps, + preKnownWidth: displayCriteria?.width ?? 0, + preKnownHeight: displayCriteria?.height ?? 0, + hasVideoUrl: result.videoUrl != null, + isTranscoding: result.isTranscoding, + ensureAudioFocus: ensureAudioFocus, + ); + if (frameRatePlan == null || (staleGuard != null && !staleGuard())) return null; + + final wtOwnsStart = watchTogetherOwnsStart(); + final shouldAutoStart = resolveShouldAutoStart(wtOwnsStart); + var openSession = session; + var openSubtitleSelection = subtitleSelection; + late _ExternalSubtitleOpenPlan externalSubtitlePlan; + + // Open video through Player + if (result.videoUrl != null) { + // Reset first frame flag and frame rate retry counter for new video + if (clearFirstFrameForOpen) _hasFirstFrame.value = false; + _frameRate.resetForNewItem(); + if (frameRatePlan.countsAsApplied) { + _frameRate.applied = true; + } + + if (beforePrime != null && !await beforePrime()) return null; + + await _primeDisplayCriteria( + player: currentPlayer, + settingsService: settingsService, + displayCriteria: displayCriteria, + isTranscoding: result.isTranscoding, + ); + + if (beforeArm != null && !await beforeArm()) return null; + + frameRatePlan.armStartupRefreshGate(currentPlayer); + externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: openSubtitleSelection.sidecarsAtOpen, + ); + final shouldAutoPlay = + shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup; + + // Backends that support at-open sidecars receive them with open() + // so tracks are discovered in a single prepare/loadfile cycle. Any + // backend that cannot do that still uses the post-open sub-add path. + final openTiming = _playbackOpenTiming( + isTranscoding: result.isTranscoding, + resumePosition: resumePosition(), + durationMs: metadata.durationMs, + ); + if (!isCurrent()) return null; + final openResult = await _openMediaOnPlayer( + player: currentPlayer, + settingsService: settingsService, + videoUrl: result.videoUrl!, + isTranscoding: result.isTranscoding, + isLocalMedia: isLocalMedia, + selectedVersion: result.selectedVersion, + timing: openTiming, + headers: headers, + play: deferAutomotiveStart ? shouldAutoPlay && !PlatformDetector.isAutomotive() : shouldAutoPlay, + externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, + shouldContinue: isCurrent, + onOpening: onOpening, + onOpened: onOpened, + onMediaAvailabilityChanged: onMediaAvailabilityChanged, + ); + // A false didOpen means shouldContinue stopped the sequence pre-open; + // open failures throw to the caller instead. + if (!openResult.didOpen || !isCurrent()) return null; + if (openResult.sidecarFallbackUsed) { + openSession = _commitSidecarFallbackSession(openSession); + openSubtitleSelection = openSession.subtitleSelection; + externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []); + } + + if (!await afterMediaOpened(shouldAutoPlay, frameRatePlan.holdPlaybackStart, wtOwnsStart)) return null; + } else { + externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: openSubtitleSelection.sidecarsAtOpen, + waitForFileLoaded: false, + ); + } + + if (beforeTrackSetup != null && !await beforeTrackSetup()) return null; + + // Track manager: owns track selection, external subtitle loading, and Plex + // immediate stream writes. Jellyfin persists selected stream indexes through + // playback progress reports instead. + final trackManager = _buildTrackManager( + forPlayer: currentPlayer, + metadata: metadata, + plexClient: plexClient(), + getProfileSettings: getProfileSettings, + preferredAudioTrack: preferredAudioTrack, + // A declined preference stays alive for the native passes instead of + // being frozen into off: the resolver's off verdict would turn a + // metadata mismatch into a navigation-priority off that no late track + // can undo (#1785). + preferredSubtitleTrack: + openSubtitleSelection.declinedPreference ?? + SubtitlePreference.trackOrNull(openSubtitleSelection.primaryTrack), + preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(openSubtitleSelection.secondaryTrack), + // A source-backed primary with no sidecar on a transcode is one the + // server burned into the picture: it is already visible, and no native + // track will ever arrive to match it. + primarySubtitleIsServerRendered: + primarySubtitleTranscoding() && + openSubtitleSelection.primarySourceStreamId != null && + openSubtitleSelection.primarySidecar == null, + ); + _trackManager = trackManager; + + // Store only the active sidecars for re-use after backend fallback. + trackManager.cacheExternalSubtitles(openSubtitleSelection.sidecarsAtOpen); + + final resumeForStartupFrame = + shouldAutoStart && frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd; + await _applyTracksAfterOpen( + trackManager: trackManager, + externalSubtitlePlan: externalSubtitlePlan, + // When the startup gate below owns the resume, skip this one to avoid + // a double-play, and never resume a player a newer flow owns. Paused + // and Watch Together-owned starts arm selection through the + // resume-skipped branch instead. Post-open external-subtitle paths are + // the exception: after they attach we must resume once so mpv can + // produce the startup frame the decoder-refresh gate is waiting for. + shouldResumeAfterSubtitleLoad: () => + shouldAutoStart && + (!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) && + mounted && + player == currentPlayer, + applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart, + ); + if (staleGuard != null && !staleGuard()) return null; + + await _releaseFrameRateStartupGate( + currentPlayer: currentPlayer, + settingsService: settingsService, + plan: frameRatePlan, + // Paused opens use the same no-resume branch as an externally + // coordinated start: track selection is armed without manufacturing a + // new play intent, and a Watch Together owner also gets its readiness + // hold released. + resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate( + currentPlayer: currentPlayer, + externalSubtitlePlan: externalSubtitlePlan, + reason: reason, + shouldResume: shouldAutoStart, + watchTogetherOwnsStart: wtOwnsStart, + wtStartupHold: wtStartupHold?.call(), + ), + playbackResumedForStartupFrame: resumeForStartupFrame, + ); + + return _ResolvedMediaOpenResult( + session: openSession, + subtitleSelection: openSubtitleSelection, + trackManager: trackManager, + externalSubtitlePlan: externalSubtitlePlan, + frameRatePlan: frameRatePlan, + ); + } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index fe8ccc3bb..6745af1c1 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -156,7 +156,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } final result = playbackContext.result; final streamHeaders = playbackContext.streamHeaders; - var subtitleSelection = await _resolveSubtitleSelectionForOpen( + final subtitleSelection = await _resolveSubtitleSelectionForOpen( metadata: _currentMetadata, result: result, preferredAudioTrack: _preferredAudioTrack, @@ -167,7 +167,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // Initial start has no previous session to protect, so commit as soon // as the resolve lands (reload-style flows commit at the open // boundary instead). - var session = PlaybackSession.fromContext( + final session = PlaybackSession.fromContext( playbackContext, requestedQualityPreset: _selectedQualityPreset, requestedMediaSourceId: _requestedMediaSourceId, @@ -180,7 +180,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // when MediaCodec has already produced its first paused frame. final settingsService = await SettingsService.getInstance(); if (!attempt.isCurrent) return; - final displayCriteria = result.mediaInfo?.displayCriteria; var audioFocusReady = false; Future ensureAudioFocus() async { @@ -195,216 +194,119 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { audioFocusReady = true; } - final frameRatePlan = await _prepareFrameRateForOpen( + Duration? resumePosition; + PlexClient? plexClientForTracks; + Completer? wtStartupHold; + + final flow = await _openResolvedMedia( currentPlayer: currentPlayer, settingsService: settingsService, - preKnownFps: displayCriteria?.fps, - preKnownWidth: displayCriteria?.width ?? 0, - preKnownHeight: displayCriteria?.height ?? 0, - hasVideoUrl: result.videoUrl != null, - isTranscoding: result.isTranscoding, + metadata: _currentMetadata, + result: result, + session: session, + subtitleSelection: subtitleSelection, + headers: streamHeaders, + isLocalMedia: _isOfflinePlayback, + isCurrent: () => attempt.isCurrent, + // When a Watch Together session is active the sync layer owns the + // start: open paused everywhere and let the host coordinate one + // simultaneous group start. + watchTogetherOwnsStart: _watchTogetherOwnsPlaybackStart, + resolveShouldAutoStart: (wtOwnsStart) => !wtOwnsStart, + resumePosition: () => resumePosition, + plexClient: () => plexClientForTracks, + getProfileSettings: () => context.read().profileSettings, + preferredAudioTrack: _preferredAudioTrack, + primarySubtitleTranscoding: () => _isTranscoding, ensureAudioFocus: ensureAudioFocus, + clearFirstFrameForOpen: true, + deferAutomotiveStart: true, + beforePrime: () async { + // Request audio focus before starting playback (Android) + // This causes other media apps (Spotify, podcasts, etc.) to pause. + // Fired in parallel with MPV setup in `_initializePlayer`; we await + // the in-flight future here (usually already resolved). + await ensureAudioFocus(); + if (!attempt.isCurrent) return false; + + resumePosition = await _resolveOpenResumePosition( + metadata: _currentMetadata, + isOffline: _isOfflinePlayback, + offlineWatchService: offlineWatchService, + ); + return mounted && player == currentPlayer; + }, + afterMediaOpened: (shouldAutoPlay, holdPlaybackStart, wtOwnsStart) async { + // Attach player to Watch Together session for sync (if in session). + // With a frame-rate startup gate pending, sync readiness waits for + // its release so the group start can't fire mid display switch. + if (mounted && !_isOfflinePlayback) { + if (wtOwnsStart && holdPlaybackStart) { + wtStartupHold = Completer(); + } + _attachToWatchTogetherSession(startupHold: wtStartupHold?.future); + _notifyWatchTogetherMediaChange(); + } + if (shouldAutoPlay && PlatformDetector.isAutomotive()) { + await _playWithPlaybackIntent(currentPlayer); + if (!attempt.isCurrent) return false; + } + return true; + }, + beforeTrackSetup: () async { + // Versions/mediaInfo come from the committed session; rebuild so the + // controls pick them up. + if (!mounted) return false; + final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId)); + plexClientForTracks = mediaClient is PlexClient ? mediaClient : null; + _resetScrubPreviewForNewItem( + metadata: _currentMetadata, + mediaInfo: result.mediaInfo, + mediaClient: mediaClient, + ); + + await _initVideoFilterAndPip(); + if (!attempt.isCurrent) return false; + + if (player == currentPlayer) { + // Auto-PiP: set up callback for API 26-30 path and initial state + if (_autoPipEnabled) { + void autoPipEnteringCallback() { + if (!mounted || player != currentPlayer) return; + _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); + _preparePipFiltersForEntry(); + } + + _autoPipEnteringCallback = autoPipEnteringCallback; + PipService.onAutoPipEntering = autoPipEnteringCallback; + if (currentPlayer.state.playing) { + unawaited(_updateAutoPipState(isPlaying: true)); + } + } + + // Shader Service (MPV only) + _shaderService = ShaderService(currentPlayer); + if (_shaderService!.isSupported) { + // Ambient Lighting Service + _ambientLightingService = AmbientLightingService(currentPlayer); + _shaderService!.ambientLightingService = _ambientLightingService; + _videoFilterManager?.ambientLightingService = _ambientLightingService; + + await _applySavedShaderPreset(); + await _restoreAmbientLighting(); + } + } + return attempt.isCurrent; + }, + wtStartupHold: () => wtStartupHold, + onMediaAvailabilityChanged: (available) => primaryMediaOpened = available, ); - if (frameRatePlan == null) return; - final shouldHoldPlaybackStart = frameRatePlan.holdPlaybackStart; - - // When a Watch Together session is active the sync layer owns the - // start: open paused everywhere and let the host coordinate one - // simultaneous group start. - final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); - Completer? wtStartupHold; - late _ExternalSubtitleOpenPlan externalSubtitlePlan; - - // Open video through Player - if (result.videoUrl != null) { - // Reset first frame flag and frame rate retry counter for new video - _hasFirstFrame.value = false; - _frameRate.resetForNewItem(); - if (frameRatePlan.countsAsApplied) { - _frameRate.applied = true; - } - - // Request audio focus before starting playback (Android) - // This causes other media apps (Spotify, podcasts, etc.) to pause. - // Fired in parallel with MPV setup in `_initializePlayer`; we await - // the in-flight future here (usually already resolved). - await ensureAudioFocus(); - if (!attempt.isCurrent) return; - - final resumePosition = await _resolveOpenResumePosition( - metadata: _currentMetadata, - isOffline: _isOfflinePlayback, - offlineWatchService: offlineWatchService, - ); - if (!mounted || player != currentPlayer) return; - - await _primeDisplayCriteria( - player: currentPlayer, - settingsService: settingsService, - displayCriteria: displayCriteria, - isTranscoding: result.isTranscoding, - ); - - frameRatePlan.armStartupRefreshGate(currentPlayer); - externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( - player: currentPlayer, - externalSubtitles: subtitleSelection.sidecarsAtOpen, - ); - final shouldAutoPlay = - !shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup; - - // Backends that support at-open sidecars receive them with open() - // so tracks are discovered in a single prepare/loadfile cycle. Any - // backend that cannot do that still uses the post-open sub-add path. - final openTiming = _playbackOpenTiming( - isTranscoding: result.isTranscoding, - resumePosition: resumePosition, - durationMs: _currentMetadata.durationMs, - ); - if (!attempt.isCurrent) return; - final openResult = await _openMediaOnPlayer( - player: currentPlayer, - settingsService: settingsService, - videoUrl: result.videoUrl!, - isTranscoding: result.isTranscoding, - isLocalMedia: _isOfflinePlayback, - selectedVersion: result.selectedVersion, - timing: openTiming, - headers: streamHeaders, - play: shouldAutoPlay && !PlatformDetector.isAutomotive(), - externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, - shouldContinue: () => attempt.isCurrent, - onMediaAvailabilityChanged: (available) => primaryMediaOpened = available, - ); - if (!openResult.didOpen || !attempt.isCurrent) return; - if (openResult.sidecarFallbackUsed) { - session = _commitSidecarFallbackSession(session); - subtitleSelection = session.subtitleSelection; - externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []); - } - - // Attach player to Watch Together session for sync (if in session). - // With a frame-rate startup gate pending, sync readiness waits for - // its release so the group start can't fire mid display switch. - if (mounted && !_isOfflinePlayback) { - if (wtOwnsStart && shouldHoldPlaybackStart) { - wtStartupHold = Completer(); - } - _attachToWatchTogetherSession(startupHold: wtStartupHold?.future); - _notifyWatchTogetherMediaChange(); - } - if (shouldAutoPlay && PlatformDetector.isAutomotive()) { - await _playWithPlaybackIntent(currentPlayer); - if (!attempt.isCurrent) return; - } - } else { - externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( - player: currentPlayer, - externalSubtitles: subtitleSelection.sidecarsAtOpen, - waitForFileLoaded: false, - ); - } - - // Versions/mediaInfo come from the committed session; rebuild so the - // controls pick them up. - if (mounted) { - final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId)); - _resetScrubPreviewForNewItem(metadata: _currentMetadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient); - - await _initVideoFilterAndPip(); - if (!attempt.isCurrent) return; - - if (player == currentPlayer) { - // Auto-PiP: set up callback for API 26-30 path and initial state - if (_autoPipEnabled) { - void autoPipEnteringCallback() { - if (!mounted || player != currentPlayer) return; - _setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering'); - _preparePipFiltersForEntry(); - } - - _autoPipEnteringCallback = autoPipEnteringCallback; - PipService.onAutoPipEntering = autoPipEnteringCallback; - if (currentPlayer.state.playing) { - unawaited(_updateAutoPipState(isPlaying: true)); - } - } - - // Shader Service (MPV only) - _shaderService = ShaderService(currentPlayer); - if (_shaderService!.isSupported) { - // Ambient Lighting Service - _ambientLightingService = AmbientLightingService(currentPlayer); - _shaderService!.ambientLightingService = _ambientLightingService; - _videoFilterManager?.ambientLightingService = _ambientLightingService; - - await _applySavedShaderPreset(); - await _restoreAmbientLighting(); - } - } - if (!attempt.isCurrent) return; - - // Track manager: owns track selection, external subtitle loading, and Plex - // immediate stream writes. Jellyfin persists selected stream indexes through - // playback progress reports instead. - _trackManager = _buildTrackManager( - forPlayer: currentPlayer, - metadata: _currentMetadata, - plexClient: mediaClient is PlexClient ? mediaClient : null, - getProfileSettings: () => context.read().profileSettings, - preferredAudioTrack: _preferredAudioTrack, - // Same rule as the reload flow: a declined preference is retried by - // the native passes instead of being frozen into off (#1785). - preferredSubtitleTrack: - subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack), - preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack), - // Same rule as the reload flow: a source-backed primary with no sidecar on a transcode is - // burned into the picture, so nothing native is coming for it. - primarySubtitleIsServerRendered: - _isTranscoding && - subtitleSelection.primarySourceStreamId != null && - subtitleSelection.primarySidecar == null, - ); - - // Store only the active sidecars for re-use after backend fallback. - _trackManager!.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen); - - final resumeForStartupFrame = - frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart; - await _applyTracksAfterOpen( - trackManager: _trackManager!, - externalSubtitlePlan: externalSubtitlePlan, - // When a startup gate below owns the resume, skip this one to - // avoid a double-play. Post-open external-subtitle paths are the - // exception: after they attach we must resume once so mpv can - // produce the startup frame that the decoder-refresh gate is waiting - // for. - // Watch Together stays paused for the group start, so selection is - // armed through the resume-skipped branch. - shouldResumeAfterSubtitleLoad: () => - (!shouldHoldPlaybackStart || resumeForStartupFrame) && !wtOwnsStart && mounted && player == currentPlayer, - applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart, - ); - - await _releaseFrameRateStartupGate( - currentPlayer: currentPlayer, - settingsService: settingsService, - plan: frameRatePlan, - resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate( - currentPlayer: currentPlayer, - externalSubtitlePlan: externalSubtitlePlan, - reason: reason, - shouldResume: !wtOwnsStart, - watchTogetherOwnsStart: wtOwnsStart, - wtStartupHold: wtStartupHold, - ), - playbackResumedForStartupFrame: resumeForStartupFrame, - ); - // Backstop: if the gate never ran its resume path (unmounted race), - // don't leave Watch Together readiness held forever. - if (wtStartupHold != null && !wtStartupHold.isCompleted) { - wtStartupHold.complete(); - } + if (flow == null) return; + // Backstop: if the gate never ran its resume path (unmounted race), + // don't leave Watch Together readiness held forever. + final startupHold = wtStartupHold; + if (startupHold != null && !startupHold.isCompleted) { + startupHold.complete(); } } on PlaybackException catch (e, st) { appLogger.w('Playback initialization failed', error: e, stackTrace: st);