fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Sincea1b6a8971sidecars 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. Since2b3853a88every 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:
@@ -12,6 +12,17 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
return;
|
||||
}
|
||||
|
||||
// A burned-in subtitle is pixels rather than a track: there is nothing selected to hide, and
|
||||
// `setSubtitleVisibility` could not remove painted pixels anyway. Only a new negotiation can,
|
||||
// and that is a real subtitle *choice* - it re-encodes the stream and the server remembers it.
|
||||
// Doing that behind a transient visibility shortcut would silently overwrite the viewer's saved
|
||||
// selection with Off, so the shortcut says where the control actually lives instead of
|
||||
// pretending to work or doing nothing at all.
|
||||
if (_hasBurnedSourceSubtitle()) {
|
||||
showAppSnackBar(context, t.messages.burnedSubtitlesUseMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
final currentTrack = widget.player.state.track.subtitle;
|
||||
// Nothing to hide when no subtitle track is selected.
|
||||
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
|
||||
@@ -19,6 +30,26 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
_setSubtitleVisibility(false);
|
||||
}
|
||||
|
||||
/// Whether the server burned the selected subtitle into the picture.
|
||||
///
|
||||
/// The same rule the player screen applies to a subtitle *change*, asked with an off target: only
|
||||
/// a burned current selection forces the server's hand, and a selection delivered as a file stays
|
||||
/// an ordinary native track the player can hide itself. Shared rather than restated so the two
|
||||
/// cannot drift.
|
||||
bool _hasBurnedSourceSubtitle() {
|
||||
final choice = widget.selectedSubtitleChoice;
|
||||
final sourceStreamId = choice != null && !choice.isOff ? choice.sourceStreamId : null;
|
||||
return PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
||||
isTranscoding: widget.isTranscoding,
|
||||
currentSourceStreamId: sourceStreamId,
|
||||
currentSelectionHasSidecar:
|
||||
sourceStreamId != null &&
|
||||
widget.sourceSubtitleSidecars.any((sidecar) => sidecar.sourceStreamId == sourceStreamId),
|
||||
targetIsOff: true,
|
||||
targetIsExternalFile: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSubtitleTrackChanged(SubtitleTrack track) {
|
||||
// Reset visibility when user explicitly picks a new subtitle track
|
||||
if (track.id != 'no' && !_subtitlesVisible) {
|
||||
|
||||
@@ -105,13 +105,15 @@ part 'parts/visibility.dart';
|
||||
/// Subtitle tracks offered in the player's "source" subtitle list.
|
||||
///
|
||||
/// Direct play exposes embedded tracks in the native player and can attach
|
||||
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
|
||||
/// or embedded codecs that the server can convert/burn into the rendition.
|
||||
/// arbitrary sidecars itself. A transcode can only deliver a resolved sidecar or
|
||||
/// an embedded codec the server can burn into the rendition — which every
|
||||
/// backend can be asked to do (Plex `subtitles=burn`, Jellyfin/Emby an `Encode`
|
||||
/// subtitle profile plus `SubtitleStreamIndex`), so the codec is the only
|
||||
/// discriminator here.
|
||||
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
||||
List<MediaSubtitleTrack> tracks, {
|
||||
required bool isTranscoding,
|
||||
required Set<int> sidecarSourceIds,
|
||||
required bool supportsEmbeddedTranscodeSelection,
|
||||
}) {
|
||||
if (!isTranscoding) {
|
||||
return tracks
|
||||
@@ -123,11 +125,24 @@ List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
||||
.toList(growable: false);
|
||||
}
|
||||
return tracks
|
||||
.where(
|
||||
(track) =>
|
||||
sidecarSourceIds.contains(track.id) ||
|
||||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
|
||||
)
|
||||
.where((track) {
|
||||
if (sidecarSourceIds.contains(track.id)) return true;
|
||||
// A row the client has to fetch itself is selectable only once its sidecar resolved: when that
|
||||
// build failed nothing can put it on screen and reloading cannot help.
|
||||
//
|
||||
// Bitmaps are not fetched at all. The transcode profile offers image formats as `Embed` with
|
||||
// no `External` entry, so the server burns them into the picture - external files included -
|
||||
// and they never get a sidecar by design. Gating those on one made the active track vanish
|
||||
// from the picker while its captions were still burned into the video, with no row left to
|
||||
// switch or turn off. So burn eligibility is decided by the codec, not by where the row came
|
||||
// from.
|
||||
final burnedByServer = CodecUtils.isImageSubtitleCodec(track.codec);
|
||||
final requiresSidecar =
|
||||
!burnedByServer &&
|
||||
(track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty));
|
||||
if (requiresSidecar) return false;
|
||||
return CodecUtils.isTranscodableSubtitleCodec(track.codec);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user