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:
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Jurnallar yükləndi",
|
||||
"logsUploadFailed": "Jurnallar yüklənə bilmədi",
|
||||
"logId": "Jurnal ID-si"
|
||||
"logId": "Jurnal ID-si",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Mətn",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Логовете са качени",
|
||||
"logsUploadFailed": "Неуспешно качване на логовете",
|
||||
"logId": "ID на лога"
|
||||
"logId": "ID на лога",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Текст",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Logfilerne blev uploadet",
|
||||
"logsUploadFailed": "Logfilerne kunne ikke uploades",
|
||||
"logId": "Log-ID"
|
||||
"logId": "Log-ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Tekst",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Protokolle hochgeladen",
|
||||
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
|
||||
"logId": "Protokoll-ID"
|
||||
"logId": "Protokoll-ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Text",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.",
|
||||
"logsUploaded": "Logs uploaded",
|
||||
"logsUploadFailed": "Failed to upload logs",
|
||||
"logId": "Log ID"
|
||||
"logId": "Log ID",
|
||||
"burnedSubtitlesUseMenu": "Subtitles are burned into this stream. Change them from the subtitle menu."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Text",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Registros subidos",
|
||||
"logsUploadFailed": "Error al subir registros",
|
||||
"logId": "ID de registro"
|
||||
"logId": "ID de registro",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Texto",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Journaux envoyés",
|
||||
"logsUploadFailed": "Échec de l’envoi des journaux",
|
||||
"logId": "Identifiant du journal"
|
||||
"logId": "Identifiant du journal",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Texte",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Naplók feltöltve",
|
||||
"logsUploadFailed": "Nem sikerült a naplók feltöltése",
|
||||
"logId": "Naplóazonosító"
|
||||
"logId": "Naplóazonosító",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Szöveg",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Log caricati",
|
||||
"logsUploadFailed": "Impossibile caricare i log",
|
||||
"logId": "ID log"
|
||||
"logId": "ID log",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Testo",
|
||||
|
||||
@@ -744,7 +744,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "ログをアップロードしました",
|
||||
"logsUploadFailed": "ログのアップロードに失敗しました",
|
||||
"logId": "ログID"
|
||||
"logId": "ログID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "テキスト",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Журналдар жүктелді",
|
||||
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
|
||||
"logId": "Журнал ID-сі"
|
||||
"logId": "Журнал ID-сі",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Мәтін",
|
||||
|
||||
@@ -744,7 +744,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "로그 업로드 완료",
|
||||
"logsUploadFailed": "로그 업로드 실패",
|
||||
"logId": "로그 ID"
|
||||
"logId": "로그 ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "텍스트",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Logger lastet opp",
|
||||
"logsUploadFailed": "Kunne ikke laste opp logger",
|
||||
"logId": "Logg-ID"
|
||||
"logId": "Logg-ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Tekst",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Logbestanden geüpload",
|
||||
"logsUploadFailed": "Uploaden van logbestanden mislukt",
|
||||
"logId": "Logboek-ID"
|
||||
"logId": "Logboek-ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Tekst",
|
||||
|
||||
@@ -756,7 +756,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Logi przesłane",
|
||||
"logsUploadFailed": "Nie udało się przesłać logów",
|
||||
"logId": "ID logu"
|
||||
"logId": "ID logu",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Tekst",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Logs enviados",
|
||||
"logsUploadFailed": "Falha ao enviar logs",
|
||||
"logId": "ID do log"
|
||||
"logId": "ID do log",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Texto",
|
||||
|
||||
@@ -756,7 +756,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Логи загружены",
|
||||
"logsUploadFailed": "Не удалось загрузить логи",
|
||||
"logId": "ID лога"
|
||||
"logId": "ID лога",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Текст",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 38876 (1767 per locale)
|
||||
/// Strings: 38877 (1767 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Loggarna har laddats upp",
|
||||
"logsUploadFailed": "Det gick inte att ladda upp loggarna",
|
||||
"logId": "Logg-ID"
|
||||
"logId": "Logg-ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Text",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Günlükler yüklendi",
|
||||
"logsUploadFailed": "Günlükler yüklenemedi",
|
||||
"logId": "Günlük Kimliği (ID)"
|
||||
"logId": "Günlük Kimliği (ID)",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Metin",
|
||||
|
||||
@@ -748,7 +748,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "Jurnallar yuklandi",
|
||||
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
|
||||
"logId": "Jurnal ID-si"
|
||||
"logId": "Jurnal ID-si",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "Matn",
|
||||
|
||||
@@ -744,7 +744,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "日誌已上傳",
|
||||
"logsUploadFailed": "上傳日誌失敗",
|
||||
"logId": "日誌 ID"
|
||||
"logId": "日誌 ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "文字",
|
||||
|
||||
@@ -744,7 +744,8 @@
|
||||
"serverBusyBody": "",
|
||||
"logsUploaded": "日志已上传",
|
||||
"logsUploadFailed": "上传日志失败",
|
||||
"logId": "日志 ID"
|
||||
"logId": "日志 ID",
|
||||
"burnedSubtitlesUseMenu": ""
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"text": "文本",
|
||||
|
||||
@@ -72,7 +72,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||
isTranscoding: _isTranscoding,
|
||||
sidecarSourceIds: sidecarSourceIds,
|
||||
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,38 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
PlaybackSourceSubtitleChoice choice, {
|
||||
required bool Function() shouldContinue,
|
||||
}) async {
|
||||
// On a transcode the server owns the picture: a burned subtitle cannot be removed or covered
|
||||
// locally, and an embedded target can only arrive by being burned in. Either way the change
|
||||
// goes back to the server rather than being applied to the running player.
|
||||
final burnSession = _playbackSession;
|
||||
final burnedSourceStreamId = burnSession?.subtitleSelection.primarySourceStreamId;
|
||||
final targetSourceStreamId = choice.sourceStreamId;
|
||||
if (PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
||||
isTranscoding: _isTranscoding,
|
||||
currentSourceStreamId: burnedSourceStreamId,
|
||||
currentSelectionHasSidecar:
|
||||
burnSession != null &&
|
||||
burnedSourceStreamId != null &&
|
||||
_sidecarForSourceStreamId(burnSession, burnedSourceStreamId) != null,
|
||||
targetIsOff: choice.isOff,
|
||||
// "A file the client fetches for itself", which the two backends mark differently: Jellyfin
|
||||
// flags the row `IsExternal`, while Plex never sets that and instead gives a real external
|
||||
// subtitle a `/library/streams/{id}` key - the same test `_selectedInternalSubtitleForHls`
|
||||
// uses to decide what it must not burn. Reading only `isExternalFile` classified every Plex
|
||||
// external file as an embedded target and sent an already-loaded track switch to the server.
|
||||
targetIsExternalFile:
|
||||
targetSourceStreamId != null &&
|
||||
(_currentMediaInfo?.subtitleTracks.any(
|
||||
(track) =>
|
||||
track.id == targetSourceStreamId &&
|
||||
(track.isExternalFile ||
|
||||
(_currentMetadata.backend == MediaBackend.plex && (track.key?.isNotEmpty ?? false))),
|
||||
) ??
|
||||
false),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (choice.isOff) {
|
||||
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||
if (!shouldContinue()) return false;
|
||||
@@ -850,6 +882,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
preferredSubtitleTrack:
|
||||
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||
// A source-backed primary with no sidecar on a transcode is one the server burned into
|
||||
// the picture: it is already visible, and no native track will ever arrive to match it.
|
||||
primarySubtitleIsServerRendered:
|
||||
result.isTranscoding &&
|
||||
subtitleSelection.primarySourceStreamId != null &&
|
||||
subtitleSelection.primarySidecar == null,
|
||||
);
|
||||
_trackManager = trackManager;
|
||||
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
||||
|
||||
@@ -99,6 +99,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
preserveSourceIdentity: preserveSubtitleSourceIdentity,
|
||||
isTranscoding: result.isTranscoding,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,6 +468,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitlePreference? preferredSubtitleTrack,
|
||||
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||
bool primarySubtitleIsServerRendered = false,
|
||||
}) {
|
||||
return TrackManager(
|
||||
player: forPlayer,
|
||||
@@ -481,6 +483,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
|
||||
showMessage: (message, {duration}) {
|
||||
if (mounted) showAppSnackBar(context, message, duration: duration);
|
||||
},
|
||||
|
||||
@@ -361,6 +361,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,6 +56,8 @@ class CodecUtils {
|
||||
'dvdsub' ||
|
||||
'vobsub' ||
|
||||
'dvb_sub' ||
|
||||
// Jellyfin's own spelling, which is what the transcode profile asks it to burn.
|
||||
'dvbsub' ||
|
||||
'dvb_subtitle' => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -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