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
+6 -2
View File
@@ -2223,6 +2223,9 @@ class Translations$messages$en {
/// en: 'Log ID'
String get logId => 'Log ID';
/// en: 'Subtitles are burned into this stream. Change them from the subtitle menu.'
String get burnedSubtitlesUseMenu => 'Subtitles are burned into this stream. Change them from the subtitle menu.';
}
// Path: subtitlingStyling
@@ -6829,6 +6832,7 @@ extension on Translations {
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.burnedSubtitlesUseMenu' => 'Subtitles are burned into this stream. Change them from the subtitle menu.',
'subtitlingStyling.text' => 'Text',
'subtitlingStyling.border' => 'Border',
'subtitlingStyling.background' => 'Background',
@@ -7152,9 +7156,9 @@ extension on Translations {
'explore.badge.available' => 'Available',
'explore.badge.partiallyAvailable' => 'Partly available',
'explore.badge.availableIn4k' => '4K available',
'explore.badge.requested' => 'Requested',
_ => null,
} ?? switch (path) {
'explore.badge.requested' => 'Requested',
'explore.badge.pendingApproval' => 'Pending approval',
'explore.badge.processing' => 'Processing',
'explore.badge.declined' => 'Declined',
@@ -7666,9 +7670,9 @@ extension on Translations {
'performanceOverlay.renderFps' => 'Render FPS',
'performanceOverlay.displayFps' => 'Display FPS',
'performanceOverlay.avSync' => 'A/V Sync',
'performanceOverlay.dropped' => 'Dropped',
_ => null,
} ?? switch (path) {
'performanceOverlay.dropped' => 'Dropped',
'performanceOverlay.dvRpus' => 'DV RPUs',
'performanceOverlay.dvRpuAverage' => 'DV RPU Avg',
'performanceOverlay.dvSampleAverage' => 'DV Sample Avg',