Files
plezy/lib/screens/video_player/parts/playback_start.dart
T
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00

433 lines
19 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _startPlayback() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer);
_hasRenderedFirstFrame = false;
_hasFatalPlaybackError = false;
// 503s observed from here on belong to this attempt's open.
_http503Watchdog.disarm();
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
try {
_hasFirstFrame.value = false;
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions(currentPlayer);
if (!attempt.isCurrent) return;
// Start the session inside the player for both backends (loading
// spinner covers Plex's tune / Jellyfin's stream negotiation).
final channel = widget.live!.channel;
final session = await _startLiveSession(channel);
if (session == null) throw Exception('Failed to start live channel');
if (!mounted || !attempt.isCurrent) {
_abandonLiveSession(session);
return;
}
_live.adoptSession(session);
// Show "Watch from Start" dialog when an existing capture session has >60s of history.
// On a fresh tune (no active recording), the buffer is empty so this won't trigger.
int? offsetSeconds;
final captureBuffer = session.captureBuffer;
final programBeginsAt = session.program.beginsAt;
if (captureBuffer != null && programBeginsAt != null) {
final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final offsetProgramStart = programBeginsAt - captureBuffer.startedAt.round();
// If a session recording started after current program start, offset of program start at will be negative.
// If a session recording started before current program start, offset of program start will be positive.
// If guide data is not available, program start will be equal to current time.
final useProgramStart = offsetProgramStart > 0 && nowEpoch - programBeginsAt > 60;
final effectiveStart = useProgramStart ? programBeginsAt : captureBuffer.seekableStartEpoch;
final elapsed = nowEpoch - effectiveStart;
appLogger.d(
'Time-shift: buffer=${captureBuffer.seekableDurationSeconds}s, '
'beginsAt=$programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)',
);
if (elapsed > 60) {
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
if (!mounted) return;
if (watchFromStart == true) {
offsetSeconds = useProgramStart ? offsetProgramStart : captureBuffer.seekStartSeconds.round();
}
}
}
// Build the stream URL (with optional offset for time-shift)
final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds);
if (streamUrl == null || !mounted) {
throw Exception('Failed to build stream path');
}
// Track stream start epoch for position calculations
if (offsetSeconds != null) {
_live.streamStartEpoch = captureBuffer!.startedAt + offsetSeconds;
_live.atLiveEdge = false;
_live.playbackStartTime = DateTime.now();
} else {
_live.markStreamRestartedAtLiveEdge();
}
await currentPlayer.setProperty('force-seekable', 'no');
await currentPlayer.open(
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
play: !PlatformDetector.isAutomotive(),
isLive: true,
);
if (!attempt.isCurrent) return;
_trackManager?.cacheExternalSubtitles(const []);
await _initVideoFilterAndPip();
if (!mounted || player != currentPlayer) return;
if (mounted) {
// Live TV never commits a PlaybackSession, so the session-derived
// versions/mediaInfo getters already read empty here.
_setPlayerState(() {
_isPlayerInitialized = true;
});
_trackManager?.mediaInfo = null;
}
if (PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);
}
} catch (e, st) {
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
unawaited(_sendLiveTimeline('stopped'));
if (mounted) {
showErrorSnackBar(context, e.toString());
unawaited(_handleBackButton());
}
}
return;
}
// Capture providers before async gaps
final offlineWatchService = context.read<OfflineWatchSyncService>();
var primaryMediaOpened = false;
try {
PlaybackContext playbackContext;
if (_offlineLibraryMode) {
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
playbackContext = await playbackResolver.resolve(
PlaybackInitializationOptions(
metadata: _currentMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: true,
);
if (playbackContext.result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
} else {
// Online path: `_playbackDataFuture` was kicked off in `_initializePlayer`
// in parallel with MPV setup. Quality preset + server capabilities +
// headers were resolved there too. Just await the result.
final playbackDataFuture = _playbackDataFuture;
if (playbackDataFuture == null) {
throw StateError('Playback data was not prepared before playback start');
}
playbackContext = await playbackDataFuture;
if (!mounted || player != currentPlayer) return;
if (playbackContext.result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
}
}
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: _currentMetadata,
result: result,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
);
if (!attempt.isCurrent) return;
// 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(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
subtitleSelection: subtitleSelection,
);
_commitPlaybackSession(session);
// Primary refresh-rate path: when metadata provides FPS, Android players
// can switch before creating decoders. MPV still needs a startup refresh
// 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<void> ensureAudioFocus() async {
if (audioFocusReady) return;
final focusFuture = _audioFocusFuture;
if (focusFuture != null) {
await focusFuture;
_audioFocusFuture = null;
} else {
await currentPlayer.requestAudioFocus();
}
audioFocusReady = true;
}
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) 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<void>? 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,
);
await _awaitTranscodeReadiness(
client: playbackContext.reportingClient,
isTranscoding: result.isTranscoding,
videoUrl: result.videoUrl!,
);
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<void>();
}
_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<UserProfileProvider>().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();
}
}
} on PlaybackException catch (e, st) {
appLogger.w('Playback initialization failed', error: e, stackTrace: st);
if (attempt.isCurrent && mounted) {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, e.message);
}
} catch (e, st) {
appLogger.e('Failed to start playback', error: e, stackTrace: st);
if (attempt.isCurrent && mounted) {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
}