Files
plezy/lib/screens/video_player/parts/playback_open.dart
T
edde746 3a704a2b9b fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.

The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2026-08-10 23:04:35 +02:00

717 lines
32 KiB
Dart

part of '../../video_player_screen.dart';
/// Outcome of the Android pre-open frame-rate negotiation for the initial
/// start flow: which pre-switch ran, whether playback must open paused
/// behind a startup gate, and which post-open follow-up (fallback switch
/// or mpv decoder refresh) releases it.
class _FrameRateStartupPlan {
_FrameRateStartupPlan({required this.fps, this.width = 0, this.height = 0});
final double? fps;
/// Native video dimensions, so a display-mode fallback can avoid downscaling
/// the video below its resolution just to match cadence (0 = unknown).
final int width;
final int height;
bool attemptedMpvPreLoad = false;
bool didPreLoadSwitch = false;
bool preOpenExoHandled = false;
bool needsPostOpenSwitch = false;
bool needsStartupRefresh = false;
Future<bool>? _startupFrameReady;
/// Whether playback must open paused behind a startup gate that
/// [_releaseFrameRateStartupGate] resumes.
bool get holdPlaybackStart => needsPostOpenSwitch || needsStartupRefresh;
/// Whether the pre-open negotiation already counts as the per-item
/// switch — keeps the post-first-frame fallback from double-switching
/// while a planned follow-up is still pending.
bool get countsAsApplied => didPreLoadSwitch || attemptedMpvPreLoad || preOpenExoHandled;
/// Subscribe to the first rendered frame *before* open() so the startup
/// decoder refresh can't miss a synchronously-fast restart event.
void armStartupRefreshGate(Player player) {
if (!needsStartupRefresh) return;
appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh');
_startupFrameReady = player.streams.playbackRestart.first
.then((_) => true)
.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh');
return false;
},
);
}
}
class _ExternalSubtitleOpenPlan {
const _ExternalSubtitleOpenPlan({required this.externalSubtitles, required this.attachesAtOpen, this.readyAfterOpen});
final List<SubtitleTrack> externalSubtitles;
final bool attachesAtOpen;
final Future<void>? readyAfterOpen;
bool get hasExternalSubtitles => externalSubtitles.isNotEmpty;
bool get requiresPostOpenAdd => !attachesAtOpen && hasExternalSubtitles;
bool get canStartBeforeTrackSetup => attachesAtOpen || !hasExternalSubtitles;
List<SubtitleTrack>? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null;
}
class _MediaOpenResult {
const _MediaOpenResult({required this.didOpen, this.sidecarFallbackUsed = false});
final bool didOpen;
final bool sidecarFallbackUsed;
}
/// 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.
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
PlaybackSession _commitSidecarFallbackSession(PlaybackSession session) {
return _updatePlaybackSessionSubtitleSelection(session, const PlaybackSubtitleSelection.off());
}
Future<PlaybackSubtitleSelection> _resolveSubtitleSelectionForOpen({
required MediaItem metadata,
required PlaybackInitializationResult result,
AudioTrack? preferredAudioTrack,
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool preserveSubtitleSourceIdentity = true,
}) async {
await _waitForProfileSettingsIfNeeded();
if (!mounted) return const PlaybackSubtitleSelection.off();
return PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: result.mediaInfo,
sidecars: result.subtitleSidecars,
profileSettings: context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
preserveSourceIdentity: preserveSubtitleSourceIdentity,
isTranscoding: result.isTranscoding,
);
}
/// Prime native display matching (tvOS HDMI mode) from server metadata
/// before the decoder emits stream properties. The native side resolves
/// only after any resulting display-mode switch has settled, plus the
/// user-configured extra delay on Apple TV.
Future<void> _primeDisplayCriteria({
required Player player,
required SettingsService settingsService,
required MediaDisplayCriteria? displayCriteria,
required bool isTranscoding,
}) {
return player.setDisplayCriteria(
!isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
extraDelayMs: PlatformDetector.isAppleTV() ? settingsService.read(SettingsService.displaySwitchDelay) * 1000 : 0,
);
}
/// Ask the platform to renegotiate the display refresh rate for [fps],
/// arming the MediaSession pause-suppression window first. The native call
/// returns only after the real display-change event (+ settle + the
/// user-configured delay). Returns whether a switch was initiated.
Future<bool> _switchDisplayFrameRateForOpen({
required Player player,
required SettingsService settingsService,
required double fps,
required int durationMs,
int videoWidth = 0,
int videoHeight = 0,
}) {
final delaySec = settingsService.read(SettingsService.displaySwitchDelay);
_frameRate.beginSuppressWindow(delaySec);
return player.setVideoFrameRate(
fps,
durationMs,
extraDelayMs: delaySec * 1000,
videoWidth: videoWidth,
videoHeight: videoHeight,
);
}
/// Whether the Android pre-open frame-rate negotiation applies: the user
/// opted into per-content refresh-rate matching and metadata already told
/// us the target fps. Shared by the start and reload flows so the
/// eligibility rule cannot drift between them.
bool _shouldAutoSwitchFrameRateForOpen(SettingsService settingsService, double? fps) {
return Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate) && fps != null && fps > 0;
}
/// Resolve where a fresh open should start: explicit request → locally
/// tracked offline progress → server view offset.
Future<Duration?> _resolveOpenResumePosition({
required MediaItem metadata,
required bool isOffline,
required OfflineWatchSyncService offlineWatchService,
Duration? requested,
}) async {
if (requested != null) return requested;
// In offline mode, prefer locally tracked progress over the cached server
// value since the user may have watched further since downloading.
if (isOffline) {
final localOffset = await offlineWatchService.getLocalViewOffset(metadata.globalKey);
if (localOffset != null && localOffset > 0) {
appLogger.d('Resuming offline playback from local progress: ${localOffset}ms');
return Duration(milliseconds: localOffset);
}
}
return metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : null;
}
/// Run the Android pre-open frame-rate strategy for the initial start:
/// mpv switches before load (its decoder must start after the mode change,
/// then gets a startup refresh); ExoPlayer switches before open (after
/// audio focus, so AudioTrack passthrough survives the renegotiation);
/// anything that could not switch up front falls back to a post-open
/// switch that holds playback start. Returns null when the screen/player
/// went stale mid-switch and the caller must bail.
Future<_FrameRateStartupPlan?> _prepareFrameRateForOpen({
required Player currentPlayer,
required SettingsService settingsService,
required double? preKnownFps,
required bool hasVideoUrl,
required bool isTranscoding,
required Future<void> Function() ensureAudioFocus,
int preKnownWidth = 0,
int preKnownHeight = 0,
}) async {
final plan = _FrameRateStartupPlan(fps: preKnownFps, width: preKnownWidth, height: preKnownHeight);
final willAutoSwitch = _shouldAutoSwitchFrameRateForOpen(settingsService, preKnownFps);
// willAutoSwitch is Android-only, so the strategy fork below is between
// the two Android backends: mpv needs its decoder refreshed after a
// display switch (pre-load path), ExoPlayer switches pre-open instead.
final isAndroidMpv = currentPlayer.needsDecoderRefreshAfterDisplaySwitch;
// Independent of matchContentFrameRate: ExoPlayer needs the rate even when the
// display never switches, because it also decides whether video tunneling is
// safe for this item. Neither the Matroska nor the MP4 extractor populates
// Format.frameRate, and a tunneled session renders no frames back for the
// native FPS detector, so metadata is the only source.
//
// Source-side only, like _primeDisplayCriteria: a transcode's metadata rate
// describes the original file, not what the server is about to send. "0" clears
// a stale rate carried over from the previous item.
if (Platform.isAndroid && !isAndroidMpv) {
final directPlayFps = isTranscoding ? null : preKnownFps;
await currentPlayer.setProperty('content-frame-rate', (directPlayFps ?? 0).toString());
}
final needsMpvPreLoad = willAutoSwitch && isAndroidMpv && hasVideoUrl;
final needsExoPreOpen = willAutoSwitch && !isAndroidMpv && hasVideoUrl;
plan.needsPostOpenSwitch = willAutoSwitch && !needsMpvPreLoad && !needsExoPreOpen;
plan.attemptedMpvPreLoad = needsMpvPreLoad;
// MPV on Android can decode and present its first paused frame before a
// post-open display switch settles. Switch first when metadata already
// gives us the FPS so MediaCodec starts after the display mode change.
if (needsMpvPreLoad) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
appLogger.d('Frame rate matching: pre-load MPV switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
plan.didPreLoadSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return null;
if (plan.didPreLoadSwitch) {
_frameRate.applied = true;
plan.needsStartupRefresh = true;
}
appLogger.d(
'Frame rate matching: pre-load MPV switch complete '
'(switched=${plan.didPreLoadSwitch}, startupRefresh=${plan.needsStartupRefresh})',
);
} catch (e) {
appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.needsStartupRefresh = false;
}
}
// ExoPlayer prepares AudioTrack during open() even when opened paused.
// On Shield/AVR chains, switching HDMI refresh rate after that can break
// direct passthrough, so switch before ExoPlayer creates renderers.
if (needsExoPreOpen) {
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
try {
await ensureAudioFocus();
if (!mounted || player != currentPlayer) return null;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch to ${preKnownFps}fps (duration: ${durationMs}ms)');
final didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: preKnownFps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return null;
plan.preOpenExoHandled = true;
appLogger.d('Frame rate matching: pre-open ExoPlayer switch complete (switched=$didSwitch)');
} catch (e) {
appLogger.w('Failed to apply pre-open ExoPlayer frame rate matching', error: e);
plan.needsPostOpenSwitch = true;
plan.preOpenExoHandled = false;
}
}
return plan;
}
/// Release the startup gate a [_FrameRateStartupPlan] held playback
/// behind: run the post-open fallback switch, or wait for the first
/// rendered frame and refresh the mpv decoder, then resume via
/// [resumeAfterStartupGate].
Future<void> _releaseFrameRateStartupGate({
required Player currentPlayer,
required SettingsService settingsService,
required _FrameRateStartupPlan plan,
required Future<void> Function(String reason) resumeAfterStartupGate,
bool playbackResumedForStartupFrame = false,
}) async {
Future<void> resumeAfterRefresh(String reason) async {
if (playbackResumedForStartupFrame) {
appLogger.d('Frame rate matching: continuing already-resumed playback after $reason');
await _playWithPlaybackIntent(currentPlayer);
} else {
await resumeAfterStartupGate(reason);
}
}
// Fallback refresh-rate path. The player was opened paused;
// setVideoFrameRate awaits the real display-change event (+ settle +
// user delay) before returning, then we start playback.
if (plan.needsPostOpenSwitch && mounted && player == currentPlayer) {
_frameRate.applied = true;
final durationMs = _currentMetadata.durationMs ?? currentPlayer.state.duration.inMilliseconds;
bool didSwitch = false;
try {
didSwitch = await _switchDisplayFrameRateForOpen(
player: currentPlayer,
settingsService: settingsService,
fps: plan.fps!,
durationMs: durationMs,
videoWidth: plan.width,
videoHeight: plan.height,
);
if (!mounted || player != currentPlayer) return;
if (didSwitch) {
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch');
}
} catch (e) {
appLogger.w('Failed to apply pre-playback frame rate matching', error: e);
}
// Always resume — either the switch completed and we want to play,
// or no switch was needed and we need to start playback now that the
// preparation gate has been cleared.
await resumeAfterRefresh('post-open frame rate switch');
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(message: 'Pre-playback frame rate: ${plan.fps}fps, switched=$didSwitch', category: 'player'),
),
);
} else if (plan.needsStartupRefresh && mounted && player == currentPlayer) {
appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh');
final startupFrameReady = plan._startupFrameReady;
final startupReady = startupFrameReady == null ? false : await startupFrameReady;
if (mounted && player == currentPlayer) {
if (startupReady) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup');
await resumeAfterRefresh('startup decoder refresh');
} else {
appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out');
await resumeAfterRefresh('startup frame timeout');
}
}
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(
message: 'Android MPV startup decoder refresh after pre-load frame-rate switch',
category: 'player',
),
),
);
}
}
/// Resume playback once a frame-rate startup gate releases: a pending
/// post-open external-subtitle load resumes through the track manager
/// (which also arms selection), everything else plays directly. Shared by
/// the start and reload flows.
Future<void> _resumeAfterFrameRateStartupGate({
required Player currentPlayer,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
}) async {
if (!mounted || player != currentPlayer) return;
final trackManager = _trackManager;
if (trackManager == null) return;
appLogger.d('Frame rate matching: resuming playback after $reason');
if (!automotivePlaybackAllowedNow()) {
// The vehicle outranks the startup gate: releasing the frame-rate gate is not permission to
// play. Subtitle selection still has to land, or the track stays stuck waiting for it.
_playbackIntentShouldPlay = false;
if (externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
return;
}
_playbackIntentShouldPlay = true;
if (externalSubtitlePlan.requiresPostOpenAdd) {
await trackManager.resumeAfterSubtitleLoad();
} else {
await _playWithPlaybackIntent(currentPlayer);
}
}
/// Resolves the post-gate playback decision without inventing a play
/// intent. Track selection is still armed when playback must remain paused;
/// a Watch Together owner also receives its readiness release.
Future<void> _finishPlaybackAfterStartupGate({
required Player currentPlayer,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required String reason,
required bool shouldResume,
required bool watchTogetherOwnsStart,
Completer<void>? wtStartupHold,
}) async {
if (shouldResume) {
return _resumeAfterFrameRateStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
);
}
appLogger.d(
watchTogetherOwnsStart
? 'Frame rate matching: yielding post-gate resume to Watch Together ($reason)'
: 'Frame rate matching: preserving paused playback after $reason',
);
final trackManager = _trackManager;
if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
if (watchTogetherOwnsStart && wtStartupHold != null && !wtStartupHold.isCompleted) {
wtStartupHold.complete();
}
}
/// Push the user's subtitle style to the native rendering layer (no-op on
/// mpv backends, which style via `sub-*` properties). Must run after
/// open() since that's when ExoPlayer initializes its subtitle views.
Future<void> _applyNativeSubtitleStyle(Player player, SettingsService settingsService) {
return player.setSubtitleStyle(
fontSize: settingsService.read(SettingsService.subtitleFontSize).toDouble(),
textColor: settingsService.read(SettingsService.subtitleTextColor),
borderSize: settingsService.read(SettingsService.subtitleBorderSize).toDouble(),
borderColor: settingsService.read(SettingsService.subtitleBorderColor),
bgColor: settingsService.read(SettingsService.subtitleBackgroundColor),
bgOpacity: settingsService.read(SettingsService.subtitleBackgroundOpacity),
subtitlePosition: settingsService.read(SettingsService.subtitlePosition),
bold: settingsService.read(SettingsService.subtitleBold),
italic: settingsService.read(SettingsService.subtitleItalic),
anchorToScreen: settingsService.read(SettingsService.subtitleAnchorToScreen),
);
}
_ExternalSubtitleOpenPlan _prepareExternalSubtitleOpenPlan({
required Player player,
required List<SubtitleTrack> externalSubtitles,
bool waitForFileLoaded = true,
}) {
final attachesAtOpen = player.attachesExternalSubtitlesAtOpen;
final hasExternalSubtitles = externalSubtitles.isNotEmpty;
return _ExternalSubtitleOpenPlan(
externalSubtitles: externalSubtitles,
attachesAtOpen: attachesAtOpen,
readyAfterOpen: waitForFileLoaded && !attachesAtOpen && hasExternalSubtitles
? player.streams.fileLoaded.first.timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Timed out waiting for file-loaded before adding external subtitles');
},
)
: null,
);
}
/// Build the per-item [TrackManager] for a freshly opened source. The
/// start and reload flows construct it identically apart from where the
/// preferred tracks and profile settings come from.
TrackManager _buildTrackManager({
required Player forPlayer,
required MediaItem metadata,
required PlexClient? plexClient,
required MediaServerUserProfile? Function() getProfileSettings,
AudioTrack? preferredAudioTrack,
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool primarySubtitleIsServerRendered = false,
}) {
return TrackManager(
player: forPlayer,
isActive: () => mounted && player == forPlayer,
// Plex writes track changes immediately. Jellyfin persists selected
// indexes through playback progress reports.
persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null,
getProfileSettings: getProfileSettings,
waitForProfileSettings: _waitForProfileSettingsIfNeeded,
metadata: metadata,
mediaInfo: _currentMediaInfo,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
);
}
/// Apply track selection for a freshly opened source: backends that cannot
/// attach external subtitles during open use the post-open sub-add dance
/// (opened paused to avoid the issue #226 race), others arm selection
/// directly.
/// [shouldResumeAfterSubtitleLoad] lets a startup gate own the resume.
/// [applySelectionWhenResumeSkipped] is for flows that legitimately stay
/// paused (e.g. a transcode restart while paused): selection is still
/// armed and the waiting flag cleared instead of leaving both dangling.
Future<void> _applyTracksAfterOpen({
required TrackManager trackManager,
required _ExternalSubtitleOpenPlan externalSubtitlePlan,
required bool Function() shouldResumeAfterSubtitleLoad,
bool applySelectionWhenResumeSkipped = false,
}) async {
if (externalSubtitlePlan.requiresPostOpenAdd) {
trackManager.waitingForExternalSubsTrackSelection = true;
try {
await trackManager.addExternalSubtitles(
externalSubtitlePlan.externalSubtitles,
waitUntilReady: externalSubtitlePlan.readyAfterOpen,
);
} finally {
// A car must not start playing just because subtitles finished loading: the vehicle's
// verdict outranks the caller's startup gate, and a skipped resume still has to release the
// subtitle-selection wait.
final resumeWanted = shouldResumeAfterSubtitleLoad();
if (resumeWanted && automotivePlaybackAllowedNow()) {
_playbackIntentShouldPlay = true;
await trackManager.resumeAfterSubtitleLoad();
} else if (applySelectionWhenResumeSkipped || resumeWanted) {
trackManager.waitingForExternalSubsTrackSelection = false;
trackManager.applyTrackSelectionWhenReady();
}
}
} else {
// Subs attached at open time (ExoPlayer) or none: apply once tracks
// are available.
trackManager.applyTrackSelectionWhenReady();
}
}
/// Drop the previous item's scrub-preview source and kick off the async
/// thumbnail load for the new one.
void _resetScrubPreviewForNewItem({
required MediaItem metadata,
required MediaSourceInfo? mediaInfo,
required MediaServerClient? mediaClient,
}) {
_scrubPreviewSource?.dispose();
_setPlayerState(() => _scrubPreviewSource = null);
_queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient);
}
/// Per-open network stream tunings: ffmpeg auto-reconnect plus an enlarged
/// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
/// linear instead of dropping the connection on every byte seek — see
/// [networkStreamRingBytes]). Every property is always written, set or
/// reset, so a reused player never carries one item's tuning into the next
/// open. On Android with ExoPlayer active they are stashed natively and
/// replayed on the exo→mpv fallback, so keep them unconditional.
Future<void> _applyNetworkStreamTuning({
required Player player,
required bool isNetworkVod,
required bool isTranscoding,
required MediaVersion? selectedVersion,
}) async {
if (isNetworkVod) {
// Covers network drops up to 10 min; applies to transcode streams too.
//
// reconnect_on_http_error=503: without it, ffmpeg abandons a reconnect
// that gets an HTTP error and the truncated body surfaces as a clean
// mid-file EOF (#1520 — PMS answers 503 while restarting/maintenance).
// Deliberately 503 only: a persistent 500 must keep failing fast so the
// server-limit dialog (_httpStatusPattern) appears promptly, and a
// multi-code list would need mpv's %len% quoting to survive the
// comma-separated option string. While ffmpeg retries, mpv reports
// buffering, which also makes the server-online reconnect hook in
// _wirePlayerStreams reachable.
await player.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_on_http_error=503,'
'reconnect_streamed=1,reconnect_delay_max=600',
);
} else {
await player.setProperty('stream-lavf-o', '');
}
// Transcode (HLS) segment fetches happen inside ffmpeg's hls demuxer, not
// mpv's stream layer, so the reconnect options above never reach them and
// mpv's default network-timeout is inert there: a segment response PMS
// leaves open without data or error — observed when the request races a
// transcoder seek/restart — buffers forever (#1859). An explicit
// network-timeout bounds each stalled read and the demuxer-level
// reconnect options re-request the same segment instead of skipping its
// content. 20s sits above the segment-serve latency of a struggling
// transcode (reads that deliver any bytes reset the clock) and a false
// trip is a Range-resumed reconnect, not an error.
if (isNetworkVod && isTranscoding) {
await player.setProperty('network-timeout', '20');
await player.setProperty('demuxer-lavf-o', 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1');
} else {
// mpv's documented default network-timeout.
await player.setProperty('network-timeout', '60');
await player.setProperty('demuxer-lavf-o', '');
}
int? ringBytes;
if (isNetworkVod && !isTranscoding) {
// Transcode (HLS) playback only uses the mpv stream layer for the
// playlist file; segment fetches happen inside ffmpeg's hls demuxer.
final maxBytes = Platform.isAndroid
? androidStreamRingCapBytes(await PlayerAndroid.getHeapSize())
: maxStreamRingBytes;
ringBytes = networkStreamRingBytes(
container: selectedVersion?.container,
bitrateKbps: selectedVersion?.bitrate,
maxBytes: maxBytes,
);
}
if (ringBytes != null) {
appLogger.i(
'Stream ring buffer: ${ringBytes ~/ (1024 * 1024)}MiB '
'(container=${selectedVersion?.container}, bitrate=${selectedVersion?.bitrate}kbps)',
);
} else {
appLogger.d(
'Stream ring buffer: default '
'(networkVod=$isNetworkVod, transcoding=$isTranscoding, container=${selectedVersion?.container})',
);
}
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
}
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
///
/// [shouldContinue] is re-checked between the awaits so stale generations
/// stop without touching the player further. [onOpened] fires immediately
/// after open() returns (before styling) so callers can flip rollback
/// bookkeeping at the exact ownership boundary.
///
/// Returns whether open was issued and whether a selected remote sidecar
/// stalled after the primary media was ready, requiring an automatic reopen
/// without subtitles.
Future<_MediaOpenResult> _openMediaOnPlayer({
required Player player,
required SettingsService settingsService,
required String videoUrl,
required bool isTranscoding,
required bool isLocalMedia,
required MediaVersion? selectedVersion,
required _PlaybackOpenTiming timing,
Map<String, String>? headers,
required bool play,
List<SubtitleTrack>? externalSubtitlesAtOpen,
bool Function()? shouldContinue,
void Function()? onOpening,
void Function()? onOpened,
void Function(bool available)? onMediaAvailabilityChanged,
}) async {
await _applyNetworkStreamTuning(
player: player,
isNetworkVod: !isLocalMedia && !widget.isLive,
isTranscoding: isTranscoding,
selectedVersion: selectedVersion,
);
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: false);
final media = Media(videoUrl, start: timing.mediaStart, headers: headers);
final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen);
Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) {
onOpening?.call();
return player.open(
media,
// The last word on the vehicle, taken here because this is the only place media actually
// starts: callers decide `play` before awaiting resolve, tuning and track work, and a car
// that starts driving in between has already spent its restriction pausing the outgoing
// item. `DD-3` allows video no exemption, and the gated resume paths start it once parked.
play: shouldPlay && automotivePlaybackAllowedNow(),
externalSubtitles: externalSubtitles,
timelineDuration: timing.timelineDuration,
);
}
try {
await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen);
onOpened?.call();
onMediaAvailabilityChanged?.call(true);
} catch (_) {
await sidecarOpenGuard?.dispose();
rethrow;
}
var sidecarFallbackUsed = false;
final sidecarOutcome = await sidecarOpenGuard?.wait();
if (sidecarOutcome == MpvSidecarOpenOutcome.aborted) {
return const _MediaOpenResult(didOpen: true);
}
if (sidecarOutcome == MpvSidecarOpenOutcome.stalled) {
appLogger.w('Selected subtitle sidecar stalled after primary media discovery; reopening without subtitles');
if (shouldContinue != null && !shouldContinue()) {
return const _MediaOpenResult(didOpen: true);
}
await player.stop();
onMediaAvailabilityChanged?.call(false);
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true);
// Respect a pause requested while mpv was waiting on the sidecar. A
// startup gate encoded by [play] remains authoritative when it is false.
await openMedia(shouldPlay: play && _playbackIntentShouldPlay);
onMediaAvailabilityChanged?.call(true);
sidecarFallbackUsed = true;
if (mounted && (shouldContinue == null || shouldContinue())) {
showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback);
}
}
if (shouldContinue != null && !shouldContinue()) {
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
await _applyNativeSubtitleStyle(player, settingsService);
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
}