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.
This commit is contained in:
edde746
2026-08-09 07:30:47 +02:00
parent 8740a19f36
commit f4ce60611b
45 changed files with 1817 additions and 187 deletions
+1
View File
@@ -69,6 +69,7 @@ import 'scrub_preview_source.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
import '../mpv/mpv.dart';
import '../utils/codec_utils.dart';
part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/music.dart';
@@ -31,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
bool audioProfile,
bool burnSubtitles,
});
String _withApiKey(String urlOrPath);
+112 -31
View File
@@ -163,7 +163,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
);
var effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var includeExternalSubtitleDelivery = false;
String? videoUrl;
String? playSessionId;
@@ -186,6 +185,32 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
// A real external subtitle file stays a file the client fetches, on a transcode as much as on
// a direct play - it is the one case where the client genuinely holds it. Jellyfin decides
// delivery from the profile and matches on format alone, never on whether a stream is embedded
// or a file, so the two rules are only expressible per request: withhold `External` when the
// selected stream is embedded (the server then burns it in), and offer it when the selection is
// a file. Deciding it per selection is what lets both hold at once.
//
// The *effective* selection, not just an explicit one: the normal launch path sends no
// preferred track and lets the server's `DefaultSubtitleStreamIndex` decide. Reading only the
// explicit request would withhold `External` for a default that is a real file, so the server
// would burn it while the client still fetched the same file as a sidecar - two copies on
// screen, and a transcode nobody needed.
final effectiveSubtitleStreamId = requestedSubtitleStreamId == -1
? null
: requestedSubtitleStreamId ?? mediaInfo.defaultSubtitleStreamIndex;
// Text only, because that is all the profile can actually deliver externally: a bitmap file
// falls through to `Encode` and gets burned in whatever we ask for, so classifying one as
// externally delivered would leave the client fetching a copy of pixels already in the video.
final requestedSubtitleIsExternalFile =
effectiveSubtitleStreamId != null &&
mediaInfo.subtitleTracks.any(
(track) =>
track.id == effectiveSubtitleStreamId &&
track.isExternalFile &&
CodecUtils.isTextSubtitleCodec(track.codec),
);
final int? maxStreamingBitrate = wantsOriginal
? null
: isTrack
@@ -207,6 +232,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
audioStreamIndex: requestedAudioStreamId,
subtitleStreamIndex: requestedSubtitleStreamId,
audioProfile: isTrack,
// A capped preset is the only way a transcode is asked for, and on one the server
// burns the selected embedded stream in rather than serving it as a file the client
// would fetch as well. A selected external *file* keeps `External`, so it is still
// delivered as a file - the one case where the client genuinely holds it.
burnSubtitles: !wantsOriginal && !requestedSubtitleIsExternalFile,
);
chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
} catch (error, stackTrace) {
@@ -249,7 +279,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
includeExternalSubtitleDelivery = true;
} else if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
@@ -259,13 +288,26 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
// present, but lyrics flow through fetchLyrics, not the subtitle path).
//
// The burned row is excluded so it cannot be painted twice; the rest stay fetchable, which is
// what keeps a secondary track renderable over a transcode.
//
// Recomputed against the negotiated `mediaInfo`: the request's own view came from the
// pre-negotiation source, and when nothing was explicitly asked for it is the *server's*
// default that decides, which the response can report differently. An explicit request still
// wins, and an off request still burns nothing.
final negotiatedSubtitleStreamId = requestedSubtitleStreamId == -1
? null
: requestedSubtitleStreamId ?? mediaInfo.defaultSubtitleStreamIndex;
final burnedSourceStreamId = isTranscoding && !requestedSubtitleIsExternalFile ? negotiatedSubtitleStreamId : null;
final subtitleSidecars = isTrack
? const <PlaybackSubtitleSidecar>[]
: _buildExternalSubtitles(
metadata.id,
effectiveSourceId,
mediaInfo,
includeExternalDelivery: includeExternalSubtitleDelivery,
isTranscoding: isTranscoding,
burnedSourceStreamId: burnedSourceStreamId,
);
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
// Jellyfin's streaming endpoint resolves a blank MediaSourceId to its own
@@ -381,9 +423,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
/// Restrict sidecar identity to the subtitle rows this open actually fetched
/// as sidecars.
///
/// Plezy's device profile declares every subtitle format with
/// Plezy's device profile declares every *text* subtitle format with
/// `Method: External`, so Jellyfin returns `DeliveryMethod: External` and a
/// `DeliveryUrl` even for streams embedded in a direct-played container.
/// `DeliveryUrl` even for text streams embedded in a direct-played container
/// whose container cannot carry subtitles in the delivered form.
/// [_buildExternalSubtitles] correctly skips those, and the native player
/// reads them out of the container instead — but the leftover delivery URL
/// makes the shared track matchers demand a sidecar that will never load,
@@ -411,23 +454,44 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
final streamIndex = track.index ?? track.id;
final codec = track.codec;
if (sourceId == null || codec == null || codec.isEmpty) return null;
// The endpoint keys off the *format*, not the codec name Jellyfin reports: it calls SRT streams
// `subrip` and WebVTT ones `webvtt`, so the raw name would ask for `Stream.subrip` and get
// nothing. Only load-bearing since extracted rows without a `DeliveryUrl` started coming
// through here.
final extension = CodecUtils.getSubtitleExtension(codec);
final path = Uri(
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$extension'],
).path;
return path.startsWith('/') ? path : '/$path';
}
/// Sidecars this open should fetch.
///
/// Never the row the server burned in, whatever its source: those pixels are already in the
/// video, and fetching a copy would draw it twice.
///
/// Never a bitmap on a transcode either. The profile only ever offers `External` for text, so a
/// bitmap falls through to `Encode` and is burned whatever we ask for - an external bitmap *file*
/// included, which is why this is not just an embedded-row rule.
///
/// Otherwise: a real external file always, since it is a file whether the video is transcoded or
/// not; and an embedded text row only on a transcode, where Jellyfin can extract it on demand.
/// That is how a *secondary* track still renders over a transcode whose primary is painted into
/// the picture. On a direct play embedded rows are absent on purpose - the native player reads
/// them out of the container itself.
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
String itemId,
String? mediaSourceId,
MediaSourceInfo mediaInfo, {
bool includeExternalDelivery = false,
bool isTranscoding = false,
int? burnedSourceStreamId,
}) {
final externalSubtitles = <PlaybackSubtitleSidecar>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
continue;
}
if (burnedSourceStreamId != null && track.id == burnedSourceStreamId) continue;
final isText = CodecUtils.isTextSubtitleCodec(track.codec);
if (isTranscoding && !isText) continue;
if (!track.isExternalFile && !isTranscoding) continue;
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
@@ -601,6 +665,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
bool audioProfile = false,
/// Drop `External` subtitle delivery from the profile, so the server burns
/// the selected subtitle into a transcode instead of serving it alongside.
bool burnSubtitles = false,
}) async {
final query = <String, String>{
'userId': connection.userId,
@@ -683,27 +751,40 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
},
],
// Embed is listed first so a direct-played container reports its
// subtitle streams as `DeliveryMethod: Embed`, matching what the
// native player actually reads. External stays declared for every
// format because a remux or transcode drops those streams from the
// rendition and the server must hand us sidecar URLs instead; the
// server picks per play method, so both entries are required.
'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'Embed'},
{'Format': 'ass', 'Method': 'Embed'},
{'Format': 'ssa', 'Method': 'Embed'},
{'Format': 'vtt', 'Method': 'Embed'},
{'Format': 'pgssub', 'Method': 'Embed'},
{'Format': 'dvdsub', 'Method': 'Embed'},
{'Format': 'dvbsub', 'Method': 'Embed'},
{'Format': 'srt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'vtt', 'Method': 'External'},
{'Format': 'pgssub', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'dvbsub', 'Method': 'External'},
// `Embed` covers direct play and an mkv remux, where the native
// player reads the subtitle stream straight out of the container.
// Jellyfin only offers it when the delivered container can carry
// subtitles, so it is unreachable on an HLS transcode (ts/mp4) and
// is listed for every format purely for the direct paths.
//
// `External` asks the server to extract a stream and serve it as a
// subtitle file. It is offered only when the caller is not asking for
// a transcode: on a transcode the owner decision is that the server
// delivers the picture complete, so every subtitle is burned in and
// the client fetches nothing alongside it. Jellyfin matches an
// external profile by text-vs-image format and never consults whether
// the stream is embedded or a real file, so the list cannot express
// "files as files, embedded burned" - offering text `External` at all
// is what made embedded text arrive as a sidecar.
//
// With no matching `External` entry the server finds no external
// profile and falls through to `Encode`, which is also why image
// formats never appear here: a bitmap handed over as a separate
// stream alongside a transcode is not something the client can render.
'SubtitleProfiles': <Map<String, Object?>>[
const {'Format': 'srt', 'Method': 'Embed'},
const {'Format': 'ass', 'Method': 'Embed'},
const {'Format': 'ssa', 'Method': 'Embed'},
const {'Format': 'vtt', 'Method': 'Embed'},
const {'Format': 'pgssub', 'Method': 'Embed'},
const {'Format': 'dvdsub', 'Method': 'Embed'},
const {'Format': 'dvbsub', 'Method': 'Embed'},
if (!burnSubtitles) ...const [
{'Format': 'srt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'vtt', 'Method': 'External'},
],
],
},
},
@@ -160,6 +160,7 @@ class PlaybackSubtitleResolver {
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool preserveSourceIdentity = true,
bool isTranscoding = false,
}) {
final candidates = <_SubtitleCandidate>[];
final matchedSidecars = <PlaybackSubtitleSidecar>{};
@@ -238,6 +239,11 @@ class PlaybackSubtitleResolver {
secondaryCandidate = candidates
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
.firstOrNull;
// A transcode carries exactly one subtitle - the burned primary - so an embedded secondary has
// no route at all: no sidecar to fetch and no native track to land on. Kept in the committed
// selection it made `TrackManager` wait out its thirty-second deadline for a track that could
// never arrive, and then read as selected while nothing was on screen.
if (isTranscoding && secondaryCandidate?.sidecar == null) secondaryCandidate = null;
}
return PlaybackSubtitleSelection(
@@ -251,6 +257,33 @@ class PlaybackSubtitleResolver {
);
}
/// Whether a subtitle change has to go back to the server rather than being applied to the
/// running player.
///
/// Two independent reasons, both only on a transcode.
///
/// Something is burned in right now: burned pixels are not a track, so turning them off
/// client-side leaves them on screen and selecting something else draws it *over* them. Every
/// change from that state needs a fresh negotiation, whatever it changes to.
///
/// Or the target itself can only come from the server. On a transcode the only subtitle the
/// client holds is a real external file; an embedded row is delivered by being burned in, so
/// selecting one has to be negotiated. Applying it locally instead finds nothing attached and
/// reports success over a picture that never changed.
///
/// Turning off with nothing burned is a genuine local no-op, and a direct play never burns.
static bool burnRequiresRenegotiation({
required bool isTranscoding,
required int? currentSourceStreamId,
required bool currentSelectionHasSidecar,
required bool targetIsOff,
required bool targetIsExternalFile,
}) {
if (!isTranscoding) return false;
if (currentSourceStreamId != null && !currentSelectionHasSidecar) return true;
return !targetIsOff && !targetIsExternalFile;
}
/// Stable source descriptor used for an explicit user selection. Supplying
/// this as the next open's preferred track makes it the highest-priority
/// choice without retaining a stale sidecar URL.
+141 -32
View File
@@ -74,6 +74,7 @@ import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
part 'plex_client/parts/live_tv.dart';
@@ -2603,9 +2604,10 @@ class PlexClient
/// Build an HLS VOD transcode stream URL (decision + start path).
///
/// Subtitle delivery stays outside the HLS video stream. Callers attach
/// Plex subtitle sources independently, so changing subtitle tracks never
/// restarts the video transcode.
/// [selectedSubtitleTrack] is burned into the picture by the server, so
/// switching to a different embedded track needs a new transcode session.
/// Real external subtitle files are unaffected — they ride alongside as
/// sidecars the client fetches directly.
///
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
/// seeks + quality/version/audio switches within one playback so the
@@ -2619,8 +2621,11 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
int? partId,
}) async {
try {
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
final allParams = _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
@@ -2630,6 +2635,7 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
offset: offset,
selectedSubtitleTrack: selectedSubtitleTrack,
);
return await _runTranscodeDecision(
startEndpoint: _plexVideoHlsStartEndpoint,
@@ -2841,6 +2847,36 @@ class PlexClient
return false;
}
/// Point the part's server-side subtitle selection at [track] so an imminent
/// `subtitles=burn` transcode burns *that* stream.
///
/// The universal transcoder decides what to burn from the part's stored
/// selection and ignores a `subtitleStreamID` passed alongside `subtitles`:
/// asking a real PMS to burn a non-selected stream burned the selected one
/// instead. Selection therefore has to happen first, on the part itself.
///
/// A no-op unless a burnable embedded track is actually being requested —
/// external subtitle files ride along as sidecars and must not disturb the
/// server's selection, and nothing is burned when no track is chosen.
///
/// Throws when a burn *is* wanted but the selection cannot be confirmed, so
/// [buildTranscodeStartPath] reports `failed` and playback falls back to
/// direct play. That is deliberately the better outcome: direct play lets the
/// native player read the embedded track itself, whereas burning against an
/// unconfirmed selection paints whatever the server had stored — a wrong
/// language welded into the picture that the viewer cannot switch off.
@visibleForTesting
Future<void> selectSubtitleStreamForBurn({required int? partId, required MediaSubtitleTrack? track}) async {
final burnTarget = _selectedInternalSubtitleForHls(track);
if (burnTarget == null) return;
if (partId == null) {
throw StateError('Cannot burn subtitle stream ${burnTarget.id}: no part id to select it on');
}
if (!await selectStreams(partId, subtitleStreamID: burnTarget.id)) {
throw StateError('Server refused to select subtitle stream ${burnTarget.id} on part $partId for burn-in');
}
}
/// Build a music transcode stream URL (decision + start path).
///
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
@@ -2939,8 +2975,10 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
}) {
final isOriginal = preset.isOriginal;
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
);
@@ -2952,7 +2990,13 @@ class PlexClient
'partIndex': partIndex.toString(),
'protocol': _plexVideoHlsProtocol,
'fastSeek': '1',
'directPlay': isOriginal ? '1' : '0',
// A burn is a re-encode, so it contradicts direct play. Asking for both
// at once is rejected outright: measured against a real PMS,
// `directPlay=1` with `subtitles=burn` answers HTTP 400 for text and
// image subtitles alike, while `directPlay=0` answers
// `decision=transcode` on the video stream and `decision=burn` on the
// subtitle.
'directPlay': selectedInternalSubtitle == null && isOriginal ? '1' : '0',
'directStream': isOriginal ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
@@ -2964,7 +3008,14 @@ class PlexClient
'mediaBufferSize': '102400',
'session': transcodeSessionId,
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
'subtitles': 'none',
// `subtitles` is the only subtitle knob this endpoint honours. Which
// stream gets burned comes from the part's server-side selection, not
// from here: measured against a real PMS, passing `subtitleStreamID` for
// a non-selected stream burned the already-selected one instead and the
// requested stream was absent from the decision entirely. See
// [selectSubtitleStreamForBurn], which is why the burn targets the
// caller's track at all.
'subtitles': selectedInternalSubtitle != null ? 'burn' : 'none',
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
@@ -2994,6 +3045,7 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
}) {
return _buildTranscodeParams(
ratingKey: ratingKey,
@@ -3004,6 +3056,7 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
offset: offset,
selectedSubtitleTrack: selectedSubtitleTrack,
);
}
@@ -3528,6 +3581,7 @@ class PlexClient
final resolvedAudioId = carriedAudioTrack == null
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
: carriedAudioStreamId;
final requestedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
@@ -3537,11 +3591,21 @@ class PlexClient
transcodeSessionId: options.transcodeSessionId!,
audioStreamId: resolvedAudioId,
offset: options.transcodeOffset,
selectedSubtitleTrack: requestedSubtitleTrack,
partId: data.mediaInfo?.getPartId(),
);
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
// A transcode that cannot carry the requested caption is not the outcome we asked for. The
// burn path refuses codecs like `dvb_teletext`, so the decision went out as
// `subtitles=none`; accepting the stream anyway left the row selected with nothing drawing
// it and no sidecar to fall back on. Falling through reports the refusal and direct play
// delivers it, which is what the burn-refusal fallback below already does.
final burnUndeliverable =
_requestsSubtitleBurn(requestedSubtitleTrack) &&
_selectedInternalSubtitleForHls(requestedSubtitleTrack) == null;
if (!burnUndeliverable && result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo, data.videoUrl!);
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
@@ -3618,6 +3682,65 @@ class PlexClient
return tracks.first.id;
}
MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) {
if (info == null) return null;
for (final track in info.subtitleTracks) {
if (track.selected) return track;
}
return null;
}
/// Pick the subtitle stream the transcode should carry. An explicit
/// [preferred] wins; otherwise the server's own selection stands.
MediaSubtitleTrack? _resolveTranscodeSubtitleTrack(MediaSourceInfo? info, SubtitlePreference? preferred) {
if (info == null) return null;
switch (preferred) {
case null:
return _selectedSubtitleTrack(info);
case SubtitleOffPreference():
return null;
case SubtitleIntentPreference(:final intent):
return findSourceTrackForIntent(intent, info.subtitleTracks) ?? _selectedSubtitleTrack(info);
case SubtitleTrackPreference(:final track):
const sourcePrefix = 'source:';
MediaSubtitleTrack? matched;
if (track.id.startsWith(sourcePrefix)) {
final sourceId = int.tryParse(track.id.substring(sourcePrefix.length));
if (sourceId != null) {
for (final row in info.subtitleTracks) {
if (row.id == sourceId) {
matched = row;
break;
}
}
}
}
matched ??= findPlexTrackForMpvSubtitle(track, info.subtitleTracks);
return matched ?? _selectedSubtitleTrack(info);
}
}
@visibleForTesting
MediaSubtitleTrack? resolveTranscodeSubtitleTrackForTesting(MediaSourceInfo? info, SubtitlePreference? preferred) {
return _resolveTranscodeSubtitleTrack(info, preferred);
}
/// The embedded stream a transcode must burn in, or null when there is
/// nothing to burn. A track carrying a `key` is a real external subtitle
/// file the client fetches directly, so it stays a sidecar instead.
MediaSubtitleTrack? _selectedInternalSubtitleForHls(MediaSubtitleTrack? track) {
if (track == null) return null;
if (track.key != null && track.key!.isNotEmpty) return null;
return CodecUtils.isTranscodableSubtitleCodec(track.codec) ? track : null;
}
/// Whether [track] is a row a transcode would have to burn, whatever its codec.
///
/// [_selectedInternalSubtitleForHls] answers the narrower question of what can
/// actually be burned; a row it rejects still cannot survive a transcode, so the
/// two must not be confused where the decision is about what was *asked* for.
bool _requestsSubtitleBurn(MediaSubtitleTrack? track) => track != null && (track.key == null || track.key!.isEmpty);
/// Build the absolute URL for an external subtitle track on this Plex
/// server. Returns `null` for tracks that aren't external (no `/library/
/// streams/{id}` key) or when the server has no auth token.
@@ -3658,39 +3781,25 @@ class PlexClient
);
}
SubtitleTrack _containerSubtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
return SubtitleTrack(
id: 'container:${track.id}',
title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}',
language: track.languageCode,
codec: track.codec,
isDefault: track.selected,
isForced: track.forced,
isExternal: true,
isContainer: true,
uri: url,
);
}
/// Build the complete subtitle catalog for Plex transcode playback.
/// Build the subtitle sidecars for Plex transcode playback.
///
/// Real sidecar files keep their direct stream URL. Embedded subtitle
/// streams share the original media container as a subtitle-only source;
/// player backends filter that source to text tracks. Every entry is
/// preloaded so changing subtitles is a local track selection.
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo, String sourceUrl) {
/// Only real external subtitle files belong here: they are small, have a
/// direct stream URL, and cost nothing to fetch. Embedded streams are burned
/// into the picture by the transcoder, so handing the client the original
/// media container to demux would mean range-reading the whole source over
/// HTTP alongside the transcode it was meant to avoid.
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const [];
final tracks = <PlaybackSubtitleSidecar>[];
for (final sub in mediaInfo.subtitleTracks) {
try {
final directUrl = _buildSidecarSubtitleUrl(sub);
if (directUrl == null) continue;
tracks.add(
PlaybackSubtitleSidecar(
sourceStreamId: sub.id,
track: directUrl == null
? _containerSubtitleTrackFromMediaTrack(sub, sourceUrl)
: _subtitleTrackFromMediaTrack(sub, directUrl),
track: _subtitleTrackFromMediaTrack(sub, directUrl),
preload: true,
),
);
@@ -3702,8 +3811,8 @@ class PlexClient
}
@visibleForTesting
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) {
return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl);
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
return _buildTranscodeSidecarSubtitles(mediaInfo);
}
/// Build list of external subtitle tracks from media info
+12 -2
View File
@@ -680,8 +680,18 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
'directStreamAudio': directStreamAudio ? '1' : '0',
'mediaBufferSize': '157286',
'session': transcodeSessionId,
// Prevent Plex from auto-selecting and burning tuner captions into the video.
// Captions that survive direct stream remain player-selectable text tracks.
// Deliberately NOT the VOD policy, which burns the selected embedded
// stream. This path sets `directStream: 1` above, so Plex copies the
// video rather than re-encoding it: burning here would force a full
// re-encode of a live stream for a caption track that already arrives
// for free. Broadcast captions (CEA-608/708) ride inside the copied
// video bitstream and stay player-selectable, so there is nothing to
// deliver and no stream id to send. Asking for a burn would also let
// Plex auto-select a caption track the viewer never chose.
//
// Not covered: a DVB tuner's bitmap subtitles are separate streams
// rather than in-band, so whether they survive the remux is unverified
// and needs a DVB source to check.
'subtitles': 'none',
'copyts': '0',
'Accept-Language': 'en',
+56 -5
View File
@@ -24,7 +24,6 @@ typedef TrackPreferencePersister =
/// automatic track selection, server preference sync, and cycling.
///
/// Follows the same manager pattern as [VideoFilterManager]:
/// constructed with a [Player] + callbacks, mutated via public setters,
/// disposed when the player screen tears down.
class TrackManager {
final Player player;
@@ -54,6 +53,10 @@ class TrackManager {
SubtitlePreference? preferredSubtitleTrack;
SubtitlePreference? preferredSecondarySubtitleTrack;
/// The primary subtitle is burned into the video by the server, so it needs no native track and
/// none is ever coming. Set on a transcode whose selected row has no sidecar.
bool primarySubtitleIsServerRendered = false;
// ── Internal state ─────────────────────────────────────────────────
bool waitingForExternalSubsTrackSelection = false;
@@ -99,6 +102,7 @@ class TrackManager {
this.preferredAudioTrack,
this.preferredSubtitleTrack,
this.preferredSecondarySubtitleTrack,
this.primarySubtitleIsServerRendered = false,
this.showMessage,
});
@@ -269,16 +273,35 @@ class TrackManager {
}
bool _tracksReadyForSelection(Tracks tracks) {
final realSubtitleTracks = tracks.subtitle
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
.toList(growable: false);
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
// A burned-in primary is already in the picture, so no native track is ever coming for it.
// Waiting for one holds up audio and rate setup for the five-second fallback and then logs a
// missed deadline twenty-five seconds later, for a selection already on screen.
//
// Answered before the empty-list guard below: a silent video whose primary is burned and whose
// secondary is unset legitimately exposes no tracks at all, and treating that as "not ready"
// spent both waits plus selection's own ten seconds on a catalog that was already complete.
//
// A carried *secondary* is a real native track that may still be on its way, though, and only
// one selection pass ever runs - so retiring the wait here would drop it for good. Neither is
// audio: the source can advertise tracks the native catalog has not published yet, and answering
// "ready" on the subtitle question alone retired the listener before they arrived, leaving the
// preferred track unselected and playback on the engine's default.
if (primarySubtitleIsServerRendered) {
if (!_secondaryPreferenceResolves(service, realSubtitleTracks)) return false;
return !_awaitingAdvertisedAudio(tracks);
}
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
if (!hasAnyTracks) return false;
final realAudioTracks = tracks.audio
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
.toList(growable: false);
final realSubtitleTracks = tracks.subtitle
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
.toList(growable: false);
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
// Selection owns the catalog-completeness decision. A null subtitle result
@@ -286,6 +309,33 @@ class TrackManager {
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
}
/// Whether the carried secondary subtitle, if there is one, has a native track to land on.
/// Vacuously true when none is wanted, or when the backend has no secondary lane at all.
bool _secondaryPreferenceResolves(TrackSelectionService service, List<SubtitleTrack> realSubtitleTracks) {
final preference = preferredSecondarySubtitleTrack;
if (preference == null || preference is SubtitleOffPreference) return true;
if (!player.supportsSecondarySubtitles) return true;
final match = switch (preference) {
SubtitleOffPreference() => null,
SubtitleTrackPreference(:final track) =>
track.id == 'no' ? null : service.findBestSubtitleMatch(realSubtitleTracks, track),
SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, realSubtitleTracks),
};
return match != null && match.id != 'no';
}
/// Whether the source advertises audio the native catalog has not published yet.
///
/// Only asked on the burned-subtitle shortcut, which otherwise answers the
/// subtitle question alone and would retire the track listener while audio was
/// still arriving - leaving the preferred track unselected. A source that
/// advertises none (a genuinely silent video) is never waited for.
bool _awaitingAdvertisedAudio(Tracks tracks) {
final sourceAdvertisesAudio = mediaInfo?.audioTracks.isNotEmpty ?? false;
if (!sourceAdvertisesAudio) return false;
return !tracks.audio.any((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id);
}
/// Core track selection: delegates to [TrackSelectionService]. Returns
/// whether every player mutation completed for this still-active owner.
///
@@ -337,6 +387,7 @@ class TrackManager {
isActive: selectionIsActive,
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
waitForPendingSource: waitForPendingSource,
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
);
} catch (e) {
appLogger.w('Failed to apply track selection', error: e);
+30 -2
View File
@@ -916,6 +916,23 @@ class TrackSelectionService {
}
if (preferred.id.startsWith('source:')) {
// A source row delivered as its own *file* carries that file's URL on the preference, and the
// loaded track is external. `findMpvTrackForPlexSubtitle` pairs a source row with the
// container's own tracks by metadata, so it cannot see that external track at all - which
// left an extracted secondary waiting out the deadline and never appearing. The URL is both
// stronger and unambiguous, so it is tried first; a unique hit is the same file by
// definition, whatever id either side chose for it.
//
// Container tracks are excluded on purpose: several source rows share one container URL, so a
// URL hit there says nothing about *which* row it is, and the metadata matcher below is what
// waits for the intended one to be discovered.
final sidecarUri = preferred.uri;
if (sidecarUri != null && sidecarUri.isNotEmpty) {
final uriMatches = availableTracks
.where((track) => track.uri == sidecarUri && !track.isContainer)
.toList(growable: false);
if (uriMatches.length == 1) return uriMatches.single;
}
final sourceTrack = _sourceSubtitleTrack(preferred.id);
if (sourceTrack == null) return null;
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
@@ -1234,6 +1251,11 @@ class TrackSelectionService {
bool Function()? isActive,
void Function(Future<void> mutation)? onPlayerMutationDispatched,
bool waitForPendingSource = true,
/// The primary is painted into the picture, so no native subtitle track is
/// coming for it. With no secondary wanted either, a silent video legitimately
/// exposes no tracks at all and the wait below can only time out.
bool primarySubtitleIsServerRendered = false,
}) async {
final player = this.player;
if (player == null) {
@@ -1243,8 +1265,14 @@ class TrackSelectionService {
if (!canMutatePlayer()) return false;
// Wait for tracks to be loaded
if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
// Wait for tracks to be loaded, unless nothing can arrive: a burned-in primary with no
// secondary wanted has a complete catalog at zero tracks, and waiting ten seconds for one
// held the saved playback rate back with it. An explicit off is as settled as an absent
// preference, which is how `TrackManager._secondaryPreferenceResolves` reads it too.
final nothingToWaitFor =
primarySubtitleIsServerRendered &&
(preferredSecondarySubtitleTrack == null || preferredSecondarySubtitleTrack is SubtitleOffPreference);
if (!nothingToWaitFor && player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
try {
await player.streams.tracks
.where((t) => t.audio.isNotEmpty || t.subtitle.isNotEmpty)