From f4ce60611b41031e4e4025b85fab4a31fa4a4ab6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:30:47 +0200 Subject: [PATCH] fix(subtitles): let the server deliver subtitles on a transcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/i18n/az.i18n.json | 3 +- lib/i18n/bg.i18n.json | 3 +- lib/i18n/da.i18n.json | 3 +- lib/i18n/de.i18n.json | 3 +- lib/i18n/en.i18n.json | 3 +- lib/i18n/es.i18n.json | 3 +- lib/i18n/fr.i18n.json | 3 +- lib/i18n/hu.i18n.json | 3 +- lib/i18n/it.i18n.json | 3 +- lib/i18n/ja.i18n.json | 3 +- lib/i18n/kk.i18n.json | 3 +- lib/i18n/ko.i18n.json | 3 +- lib/i18n/nb.i18n.json | 3 +- lib/i18n/nl.i18n.json | 3 +- lib/i18n/pl.i18n.json | 3 +- lib/i18n/pt.i18n.json | 3 +- lib/i18n/ru.i18n.json | 3 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 8 +- lib/i18n/sv.i18n.json | 3 +- lib/i18n/tr.i18n.json | 3 +- lib/i18n/uz.i18n.json | 3 +- lib/i18n/zh-Hant.i18n.json | 3 +- lib/i18n/zh.i18n.json | 3 +- lib/screens/video_player/parts/build.dart | 1 - .../parts/episode_navigation.dart | 38 ++ .../video_player/parts/playback_open.dart | 3 + .../video_player/parts/playback_start.dart | 6 + lib/services/jellyfin_client.dart | 1 + .../parts/images_downloads.dart | 1 + .../jellyfin_client/parts/playback.dart | 143 ++++- lib/services/playback_subtitle_resolver.dart | 33 ++ lib/services/plex_client.dart | 173 +++++- lib/services/plex_client/parts/live_tv.dart | 14 +- lib/services/track_manager.dart | 61 +- lib/services/track_selection_service.dart | 32 +- lib/utils/codec_utils.dart | 2 + .../video_controls/parts/track_controls.dart | 31 + .../video_controls/video_controls.dart | 31 +- test/services/jellyfin_client_urls_test.dart | 548 +++++++++++++++++- .../playback_subtitle_resolver_test.dart | 80 +++ .../plex_playback_data_request_test.dart | 454 +++++++++++++-- test/services/track_manager_test.dart | 221 +++++++ test/utils/codec_utils_test.dart | 3 + test/widgets/video_controls_test.dart | 52 +- 45 files changed, 1817 insertions(+), 187 deletions(-) diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index a428926c..7830c175 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -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", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 2892bdf1..fe68c2c9 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Логовете са качени", "logsUploadFailed": "Неуспешно качване на логовете", - "logId": "ID на лога" + "logId": "ID на лога", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Текст", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 35765c85..07eea859 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Logfilerne blev uploadet", "logsUploadFailed": "Logfilerne kunne ikke uploades", - "logId": "Log-ID" + "logId": "Log-ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Tekst", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index f5df6b8a..959f8d6e 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Protokolle hochgeladen", "logsUploadFailed": "Protokolle konnten nicht hochgeladen werden", - "logId": "Protokoll-ID" + "logId": "Protokoll-ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Text", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 60e2eadb..0add721f 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 576c2daa..d9083576 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index e45d29a7..25110058 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 1ebe7965..60116ac6 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index ace22881..38f7b53f 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Log caricati", "logsUploadFailed": "Impossibile caricare i log", - "logId": "ID log" + "logId": "ID log", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Testo", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 47a180dc..b00a6b84 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -744,7 +744,8 @@ "serverBusyBody": "", "logsUploaded": "ログをアップロードしました", "logsUploadFailed": "ログのアップロードに失敗しました", - "logId": "ログID" + "logId": "ログID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "テキスト", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 41f044e2..1facd63d 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Журналдар жүктелді", "logsUploadFailed": "Журналдарды жүктеу мүмкін болмады", - "logId": "Журнал ID-сі" + "logId": "Журнал ID-сі", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Мәтін", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 768c8809..b1199c67 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -744,7 +744,8 @@ "serverBusyBody": "", "logsUploaded": "로그 업로드 완료", "logsUploadFailed": "로그 업로드 실패", - "logId": "로그 ID" + "logId": "로그 ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "텍스트", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 4ff95058..6602b649 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index db36b315..00f9be35 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -748,7 +748,8 @@ "serverBusyBody": "", "logsUploaded": "Logbestanden geüpload", "logsUploadFailed": "Uploaden van logbestanden mislukt", - "logId": "Logboek-ID" + "logId": "Logboek-ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Tekst", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index ecaf0bea..248a0ee7 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index fa06a975..1b7e5689 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index ab464982..581e1d78 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -756,7 +756,8 @@ "serverBusyBody": "", "logsUploaded": "Логи загружены", "logsUploadFailed": "Не удалось загрузить логи", - "logId": "ID лога" + "logId": "ID лога", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "Текст", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 0609849d..40239216 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 5e621942..0d5f6ca0 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 6e8125dd..5e9e75db 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 639e27b5..069f281a 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -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", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 7f8f110d..eef28eae 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -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", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index d98fd64b..4a52145b 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -744,7 +744,8 @@ "serverBusyBody": "", "logsUploaded": "日誌已上傳", "logsUploadFailed": "上傳日誌失敗", - "logId": "日誌 ID" + "logId": "日誌 ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "文字", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index df7014dc..f537edad 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -744,7 +744,8 @@ "serverBusyBody": "", "logsUploaded": "日志已上传", "logsUploadFailed": "上传日志失败", - "logId": "日志 ID" + "logId": "日志 ID", + "burnedSubtitlesUseMenu": "" }, "subtitlingStyling": { "text": "文本", diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 67cafb64..1fc57171 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -72,7 +72,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { _currentMediaInfo?.subtitleTracks ?? const [], isTranscoding: _isTranscoding, sidecarSourceIds: sidecarSourceIds, - supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex, ); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 8a60069c..ab70bd42 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index d00afbdf..84bc7e64 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -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); }, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 2c558f3f..575c98f4 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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. diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index f9b37fac..df64a783 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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'; diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 74b988df..e92fb640 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -31,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { bool? allowVideoStreamCopy, bool? allowAudioStreamCopy, bool audioProfile, + bool burnSubtitles, }); String _withApiKey(String urlOrPath); diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 326816ba..c3317b13 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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 [] : _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 _buildExternalSubtitles( String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo, { - bool includeExternalDelivery = false, + bool isTranscoding = false, + int? burnedSourceStreamId, }) { final externalSubtitles = []; 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 = { '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 >[ - {'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': >[ + 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'}, + ], ], }, }, diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 5edc58e4..9e7e8fb6 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -160,6 +160,7 @@ class PlaybackSubtitleResolver { SubtitlePreference? preferredSubtitleTrack, SubtitlePreference? preferredSecondarySubtitleTrack, bool preserveSourceIdentity = true, + bool isTranscoding = false, }) { final candidates = <_SubtitleCandidate>[]; final matchedSidecars = {}; @@ -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. diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 118b89b5..55f0d977 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 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 _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 _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { if (mediaInfo == null) return const []; final tracks = []; 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 buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) { - return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl); + List buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) { + return _buildTranscodeSidecarSubtitles(mediaInfo); } /// Build list of external subtitle tracks from media info diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 9b1ff2ad..c4af2880 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -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', diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index dfdf8ba2..d8b6e9d0 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -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 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); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 0bfe25f4..5971da34 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -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 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) diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index b09b6dc7..7d587c23 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -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, }; diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 5db6353e..dc55902b 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -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) { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 077fcffa..426ee91f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 selectableSourceSubtitleTracks( List tracks, { required bool isTranscoding, required Set sidecarSourceIds, - required bool supportsEmbeddedTranscodeSelection, }) { if (!isTranscoding) { return tracks @@ -123,11 +125,24 @@ List 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); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index ad7577d4..249db2b2 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -828,14 +828,444 @@ void main() { expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse); expect(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse); - expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue); - expect(result.externalSubtitles, hasLength(1)); + // Nothing is selected and the fixture declares no default, so the server burns nothing and + // this embedded row stays fetchable as an extracted file. expect(result.subtitleSidecars.single.sourceStreamId, 2); expect(result.externalSubtitles.single.title, 'English'); expect(result.externalSubtitles.single.language, 'eng'); final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!); expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt'); - expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); + }); + + /// Jellyfin picks a subtitle's delivery from the profile, and matches an external profile on + /// text-vs-image format without ever consulting whether the stream is embedded or a real file. + /// So withholding `External` is the only lever that makes it burn, and offering it at all is + /// what made an embedded stream arrive as a sidecar on a transcode. + test('subtitle delivery profile withholds External only when a transcode is requested', () async { + Future>> profileFor({required bool original}) async { + final bodies = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + bodies.add(request.body); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: original ? TranscodeQualityPreset.original : TranscodeQualityPreset.p720_2mbps, + ), + ); + final body = jsonDecode(bodies.single) as Map; + final profile = body['DeviceProfile'] as Map; + return (profile['SubtitleProfiles'] as List).cast>(); + } + + final capped = await profileFor(original: false); + expect( + capped.where((entry) => entry['Method'] == 'External'), + isEmpty, + reason: 'a transcode must find no external profile so it falls through to Encode', + ); + expect(capped.where((entry) => entry['Method'] == 'Embed'), isNotEmpty); + + final untouched = await profileFor(original: true); + expect( + untouched.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']), + containsAll(['srt', 'ass', 'ssa', 'vtt']), + reason: 'direct playback keeps text External, which is how a real file is delivered', + ); + expect( + untouched.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']), + isNot(contains('pgssub')), + reason: 'an image format the client cannot render is never offered as a file', + ); + }); + + /// The two rules are only expressible per request, so the selection decides: an embedded + /// stream is burned, a real external file is still delivered as a file. Both on a transcode. + test('a selected external file keeps External and is still sidecarred on a transcode', () async { + final bodies = []; + const externalStream = { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [externalStream], + }, + ], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + bodies.add(request.body); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1', + 'MediaStreams': [externalStream], + }, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + preferredSubtitleTrack: const SubtitlePreference.intent( + SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt', isExternal: true), + ), + ), + ); + + expect(result.isTranscoding, isTrue); + final profile = + ((jsonDecode(bodies.single) as Map)['DeviceProfile'] + as Map)['SubtitleProfiles'] + as List; + expect( + profile.cast>().where((entry) => entry['Method'] == 'External'), + isNotEmpty, + reason: 'burning a file the client already holds would be a re-encode for nothing', + ); + expect(result.subtitleSidecars.single.sourceStreamId, 3); + }); + + /// The normal launch path sends no preferred track and lets the server's default decide, so the + /// burn decision has to read the effective selection. Reading only the explicit request burned + /// a default that is a real file *and* fetched it as a sidecar -- the same subtitle twice, over + /// a transcode nobody needed. + test('a defaulted external file is not burned and is not duplicated', () async { + final bodies = []; + const externalStream = { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }; + const source = { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 3, + 'MediaStreams': [externalStream], + }; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [source], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + bodies.add(request.body); + return jsonResponse({ + 'MediaSources': [ + {...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'}, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isTrue); + final profile = + ((jsonDecode(bodies.single) as Map)['DeviceProfile'] + as Map)['SubtitleProfiles'] + as List; + expect( + profile.cast>().where((entry) => entry['Method'] == 'External'), + isNotEmpty, + reason: 'the default selection is a real file, so it must not be burned', + ); + expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3]); + }); + + /// The profile only ever offers `External` for text, so a bitmap falls through to `Encode` and + /// is burned whatever the client asks for -- an external bitmap *file* included. Treating one as + /// externally delivered left the client fetching a copy of pixels already in the video, and let + /// "off" stay local over a burn. + test('an external bitmap file is treated as burned, not fetched', () async { + final bodies = []; + const externalBitmap = { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'pgssub', + 'Language': 'eng', + 'DisplayTitle': 'English - PGS', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.sup', + }; + const source = { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 3, + 'MediaStreams': [externalBitmap], + }; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [source], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + bodies.add(request.body); + return jsonResponse({ + 'MediaSources': [ + {...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'}, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isTrue); + final profile = + ((jsonDecode(bodies.single) as Map)['DeviceProfile'] + as Map)['SubtitleProfiles'] + as List; + expect( + profile.cast>().where((entry) => entry['Method'] == 'External'), + isEmpty, + reason: 'a bitmap cannot be delivered externally, so nothing is gained by offering it', + ); + expect( + result.subtitleSidecars, + isEmpty, + reason: 'the server burns it in, so fetching the file would draw it twice', + ); + }); + + /// Unburned embedded rows are fetchable so a secondary track can still render, but only text + /// ones: a separate bitmap stream is not renderable alongside a transcode, which is exactly why + /// the profile withholds `External` for those formats in the first place. + test('an unburned embedded bitmap row is not offered as a sidecar', () async { + const textRow = { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'ass', + 'Language': 'eng', + 'DisplayTitle': 'English - ASS', + }; + const bitmapRow = { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'pgssub', + 'Language': 'swe', + 'DisplayTitle': 'Swedish - PGS', + }; + const source = { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 2, + 'MediaStreams': [textRow, bitmapRow], + }; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [source], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return jsonResponse({ + 'MediaSources': [ + {...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'}, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isTrue); + expect( + result.subtitleSidecars, + isEmpty, + reason: 'stream 2 is burned in and stream 3 is a bitmap nothing could draw', + ); + }); + + /// Jellyfin reports SRT as `subrip` and WebVTT as `webvtt`, but its extraction endpoint keys off + /// the format, so the raw codec name asks for a file that does not exist. Only load-bearing + /// since extracted rows without a `DeliveryUrl` started being fetched. + test('an extracted row uses the endpoint format, not the reported codec name', () async { + const burnedPrimary = { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'ass', + 'Language': 'eng', + 'DisplayTitle': 'English - ASS', + }; + const aliasedSecondary = { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'subrip', + 'Language': 'swe', + 'DisplayTitle': 'Swedish - SRT', + }; + const source = { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 2, + 'MediaStreams': [burnedPrimary, aliasedSecondary], + }; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [source], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return jsonResponse({ + 'MediaSources': [ + {...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'}, + ], + }); + } + return http.Response('not used', 500); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.subtitleSidecars.single.sourceStreamId, 3); + expect( + Uri.parse(result.externalSubtitles.single.uri!).path, + '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + reason: '`subrip` is the codec name; `srt` is what the endpoint serves', + ); }); test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async { @@ -1185,13 +1615,11 @@ void main() { expect(result.playMethod, 'Transcode'); expect(result.mediaInfo!.subtitleTracks, hasLength(3)); - expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue); - expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]); - expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [ - '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - '/Videos/item-1/src-1/Subtitles/4/Stream.srt', - '/Videos/item-1/src-1/Subtitles/5/Stream.srt', - ]); + // The last request matched no row, so the effective selection is the server's default + // (`DefaultSubtitleStreamIndex: 4`) and that is the stream the server burns in. It is the + // one row not fetched: a sidecar for it would paint a second copy over the burned pixels. + // The other two stay fetchable, which is what keeps a secondary track renderable. + expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 5]); }); test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async { @@ -1736,6 +2164,32 @@ void main() { expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1'); }); + test('a source above the cap without a transcode is still a refusal', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'Bitrate': 8000000}, + ], + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.fallbackReason, TranscodeFallbackReason.directPlayOnly); + }); + test('video download rejects authentication and cancellation', () async { final cases = <(String, Future Function(http.Request))>[ ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})), @@ -1855,7 +2309,7 @@ void main() { expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo')); }); - test('getPlaybackInfo advertises embedded and external subtitle delivery', () async { + test('getPlaybackInfo advertises embedded delivery for every format, external for text only', () async { Uri? capturedUri; String? capturedBody; final scoped = JellyfinClient.forTesting( @@ -1894,20 +2348,22 @@ void main() { expect(directPlayProfile['AudioCodec'], contains('mp2')); expect(profile['TranscodingProfiles'], isNotEmpty); expect(profile['CodecProfiles'], isEmpty); - const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']; + const textSubtitleFormats = ['srt', 'ass', 'ssa', 'vtt']; + const imageSubtitleFormats = ['pgssub', 'dvdsub', 'dvbsub']; final subtitleProfiles = [ for (final entry in profile['SubtitleProfiles'] as List) entry as Map, ]; - // Every format is offered both ways, Embed first: the server picks per - // play method, so direct play reports its container streams as embedded - // while a remux or transcode still hands back sidecar URLs. - expect( - subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']), - subtitleFormats, - ); + // Embed is offered for every format and listed first, so a direct play + // or mkv remux reports its container streams as embedded. External is + // text-only: the server extracts those into a small subtitle file, while + // an image format finds no external match and gets burned in instead. + expect(subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']), [ + ...textSubtitleFormats, + ...imageSubtitleFormats, + ]); expect( subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']), - subtitleFormats, + textSubtitleFormats, ); expect( subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'), @@ -1915,6 +2371,58 @@ void main() { ); }); + test('image subtitle formats are declared Embed-only so a transcode burns them in', () async { + String? capturedBody; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedBody = request.body; + return jsonResponse({'MediaSources': []}); + }), + ); + addTearDown(scoped.close); + + await scoped.getPlaybackInfo('item-1'); + + final body = jsonDecode(capturedBody!) as Map; + final profile = body['DeviceProfile'] as Map; + final subtitleProfiles = [ + for (final entry in profile['SubtitleProfiles'] as List) entry as Map, + ]; + final externalFormats = subtitleProfiles + .where((entry) => entry['Method'] == 'External') + .map((entry) => entry['Format']) + .toSet(); + final embedFormats = subtitleProfiles + .where((entry) => entry['Method'] == 'Embed') + .map((entry) => entry['Format']) + .toSet(); + + for (final format in ['pgssub', 'dvdsub', 'dvbsub']) { + expect( + externalFormats, + isNot(contains(format)), + reason: + 'Declaring $format as External makes Jellyfin match it as an external image ' + 'subtitle and hand back a stream the client cannot render on a transcode. ' + 'With no image entry the server falls through to Encode and burns it in, ' + 'which is the only way a bitmap subtitle reaches the viewer while transcoding.', + ); + expect( + embedFormats, + contains(format), + reason: 'Direct play and mkv remux read $format out of the container, so Embed must stay.', + ); + } + for (final format in ['srt', 'ass', 'ssa', 'vtt']) { + expect( + externalFormats, + contains(format), + reason: 'The server extracts $format into a small subtitle file; that path is cheap and must stay.', + ); + } + }); + test('path-encodes reserved ids for browse and watch-state endpoints', () async { final captured = []; final scoped = JellyfinClient.forTesting( diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index 48e79dc8..2a6307f6 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -228,6 +228,33 @@ void main() { expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt'); }); + test('a transcode drops a carried secondary it cannot deliver', () { + // The burn covers the primary, and an embedded secondary has neither a sidecar to fetch nor a + // native track to land on. Kept selected, it made `TrackManager` wait out its thirty-second + // deadline and then read as active while nothing was on screen. + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]), + sidecars: const [], + preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')), + isTranscoding: true, + ); + + expect(result.secondaryTrack, isNull, reason: 'nothing can carry it, so it must not stay selected'); + expect(result.secondarySourceStreamId, isNull); + }); + + test('a direct play keeps a carried secondary the player can select natively', () { + final result = PlaybackSubtitleResolver.resolve( + metadata: metadata, + mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]), + sidecars: const [], + preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')), + ); + + expect(result.secondarySourceStreamId, 3, reason: 'the container still carries it'); + }); + test('explicit off produces an open with zero sidecars', () { final result = PlaybackSubtitleResolver.resolve( metadata: metadata, @@ -711,6 +738,59 @@ void main() { expect(track.channels, 6); }); + group('burned-in subtitles force a renegotiation', () { + bool needsServer({ + bool isTranscoding = true, + int? currentSourceStreamId, + bool currentSelectionHasSidecar = false, + bool targetIsOff = false, + bool targetIsExternalFile = false, + }) => PlaybackSubtitleResolver.burnRequiresRenegotiation( + isTranscoding: isTranscoding, + currentSourceStreamId: currentSourceStreamId, + currentSelectionHasSidecar: currentSelectionHasSidecar, + targetIsOff: targetIsOff, + targetIsExternalFile: targetIsExternalFile, + ); + + // Burned pixels are not a track: off leaves them on screen, anything else is drawn over them. + // So from a burned selection the target is irrelevant - every change goes back to the server. + test('every change from a burned selection goes back to the server', () { + expect(needsServer(currentSourceStreamId: 3, targetIsOff: true), isTrue); + expect(needsServer(currentSourceStreamId: 3, targetIsExternalFile: true), isTrue); + expect(needsServer(currentSourceStreamId: 3), isTrue, reason: 'another embedded track'); + }); + + // The other direction: nothing is burned yet, but the target can only arrive burned. + test('an embedded target has to be negotiated even with nothing burned', () { + expect( + needsServer(currentSourceStreamId: null), + isTrue, + reason: 'applying it locally attaches nothing and reports success over an unchanged picture', + ); + expect( + needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true), + isTrue, + reason: 'a sidecar-backed current selection does not make an embedded target local', + ); + }); + + test('what the client already holds stays local', () { + expect(needsServer(currentSourceStreamId: null, targetIsOff: true), isFalse); + expect(needsServer(currentSourceStreamId: null, targetIsExternalFile: true), isFalse); + expect( + needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true, targetIsOff: true), + isFalse, + reason: 'a sidecar was delivered as a file, so turning it off is a real local change', + ); + }); + + test('a direct play never burns, whatever is selected', () { + expect(needsServer(isTranscoding: false, currentSourceStreamId: 3), isFalse); + expect(needsServer(isTranscoding: false), isFalse); + }); + }); + test('selected embedded subtitle keeps sidecars out of the open', () { final result = PlaybackSubtitleResolver.resolve( metadata: metadata, diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 3511c3f7..74dbecf0 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -14,6 +14,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; +import 'package:plezy/services/subtitle_preference.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; @@ -132,10 +133,7 @@ void main() { } List buildTranscodeSubtitles(PlexClient client, List subtitleTracks) { - return client.buildTranscodeSidecarSubtitlesForTesting( - mediaInfoWithSubtitles(subtitleTracks), - 'https://plex.example.com/video.mkv?X-Plex-Token=token', - ); + return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks)); } test('selectStreams sends audio stream selection with allParts', () async { @@ -247,7 +245,7 @@ void main() { expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue); }); - test('transcode initialization wires a subtitle-free HLS request to the complete sidecar catalog', () async { + test('transcode initialization burns the selected embedded stream and sidecars only the external file', () async { final requests = []; final client = makeClient((request) async { requests.add(request); @@ -310,6 +308,9 @@ void main() { headers: {'content-type': 'application/json'}, ); } + if (request.method == 'PUT' && request.url.path == '/library/parts/99') { + return http.Response('', 200); + } return http.Response('unexpected request', 500); }); addTearDown(client.close); @@ -328,19 +329,30 @@ void main() { final decisionRequest = requests.singleWhere( (request) => request.url.path == '/video/:/transcode/universal/decision', ); - expect(decisionRequest.url.queryParameters['subtitles'], 'none'); + expect(decisionRequest.url.queryParameters['subtitles'], 'burn'); expect(decisionRequest.url.queryParameters['offset'], '495.000000'); - expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse); + expect( + decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), + isFalse, + reason: 'the universal transcoder ignores it; the part selection below is what picks the stream', + ); expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse); expect(decisionRequest.url.queryParameters['X-Plex-Incomplete-Segments'], '1'); + + // What actually aims the burn: the embedded ASS track is selected on the + // part first, so the server burns 401 rather than whatever it had stored. + final selection = requests.singleWhere( + (request) => request.method == 'PUT' && request.url.path == '/library/parts/99', + ); + expect(selection.url.queryParameters['subtitleStreamID'], '401'); + expect(selection.url.queryParameters.containsKey('audioStreamID'), isFalse); expect(result.isTranscoding, isTrue); expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?')); expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000'); expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15)); - expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]); - expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue); - expect(result.subtitleSidecars.first.track.isContainer, isTrue); - expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt')); + expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]); + expect(result.subtitleSidecars.single.preload, isTrue); + expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt')); }); test('playback uses metadata availability flags without probing part URLs', () async { @@ -545,37 +557,39 @@ void main() { expect(result.selectedMediaIndex, 1); }); - test('transcode subtitle catalog includes embedded and keyed Plex streams', () { + test('transcode subtitle catalog carries only real external subtitle files', () { + // Embedded streams are burned into the picture by the transcoder. Handing them + // back as sidecars on the source container would make the client range-read the + // whole remux over HTTP while the transcode is already streaming (#1815, #1738). final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); final subtitles = buildTranscodeSubtitles(client, [ - MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false), + MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false), + MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: false, forced: false), MediaSubtitleTrack( - id: 402, + id: 403, + index: 5, codec: 'srt', languageCode: 'swe', title: 'External', selected: false, forced: false, - key: '/library/streams/402', + key: '/library/streams/403', external: true, ), ]); - expect(subtitles, hasLength(2)); - expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]); - expect(subtitles.every((sidecar) => sidecar.preload), isTrue); - expect(subtitles.first.track.isContainer, isTrue); - expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token'); - expect(subtitles.last.track.isContainer, isFalse); + expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [403]); + expect(subtitles.single.preload, isTrue); + expect(subtitles.single.track.isContainer, isFalse); expect( - subtitles.last.track.uri, - 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token', + subtitles.single.track.uri, + 'https://plex.example.com/library/streams/403.srt?encoding=utf-8&X-Plex-Token=token', ); }); - test('tokenless transcode keeps embedded and keyed subtitle sources', () { + test('tokenless transcode still sidecars the external subtitle file', () { final client = testPlexClient( serverId: ServerId('server-id'), token: null, @@ -596,17 +610,235 @@ void main() { external: true, ), ]), - 'https://plex.example.com/video.mkv', ); - expect(subtitles, hasLength(2)); - expect(subtitles.first.track.isContainer, isTrue); - expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv'); - expect(subtitles.last.track.isContainer, isFalse); - expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8'); + expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [402]); + expect(subtitles.single.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8'); }); - test('video transcode stays subtitle-free while preserving the HLS profile', () { + test('transcode burns the selected embedded stream whatever its format', () { + // Burn covers text as well as image: it is the one mechanism that keeps ASS/SSA + // styling intact through an HLS transcode. + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + Map paramsForSelected(MediaSubtitleTrack track) => client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: track, + ); + + final textParams = paramsForSelected( + MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false), + ); + expect(textParams['subtitles'], 'burn'); + expect(textParams.containsKey('subtitleStreamID'), isFalse); + expect(textParams.containsKey('advancedSubtitles'), isFalse); + + final imageParams = paramsForSelected( + MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), + ); + expect(imageParams['subtitles'], 'burn'); + expect(imageParams.containsKey('subtitleStreamID'), isFalse); + }); + + test('a burn aims at the caller track by selecting it on the part first', () async { + // The universal transcoder burns whatever the part has selected, so this + // PUT is the only thing that makes `subtitles=burn` hit the chosen stream. + final requests = []; + final client = makeClient((request) async { + requests.add(request); + return http.Response('', 200); + }); + addTearDown(client.close); + + await client.selectSubtitleStreamForBurn( + partId: 99, + track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false), + ); + + final put = requests.singleWhere((request) => request.method == 'PUT'); + expect(put.url.path, '/library/parts/99'); + expect(put.url.queryParameters['subtitleStreamID'], '401'); + }); + + test('a sidecarred external file leaves the server selection untouched', () async { + // External files are fetched directly and never burned; rewriting the + // part's selection here would change what other Plex clients see. + final requests = []; + final client = makeClient((request) async { + requests.add(request); + return http.Response('', 200); + }); + addTearDown(client.close); + + await client.selectSubtitleStreamForBurn( + partId: 99, + track: MediaSubtitleTrack( + id: 403, + index: 5, + codec: 'srt', + selected: true, + forced: false, + key: '/library/streams/403', + external: true, + ), + ); + await client.selectSubtitleStreamForBurn(partId: 99, track: null); + + expect(requests, isEmpty); + }); + + test('an unaimable burn refuses rather than letting the server pick', () async { + // Proceeding would burn whatever the part already had selected, welding a + // language the viewer never chose into the picture. + final client = makeClient((_) async => http.Response('', 200)); + addTearDown(client.close); + + await expectLater( + client.selectSubtitleStreamForBurn( + partId: null, + track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false), + ), + throwsStateError, + ); + }); + + test('a selection the server did not commit refuses the burn too', () async { + // 204 rather than 200: no HTTP error to raise, but nothing was stored + // either, so the burn target is still whatever the part had before. + final client = makeClient((_) async => http.Response('', 204)); + addTearDown(client.close); + + await expectLater( + client.selectSubtitleStreamForBurn( + partId: 99, + track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false), + ), + throwsStateError, + ); + }); + + test('a burn that cannot be aimed never reaches the decision endpoint', () async { + // The whole transcode is abandoned: the caller falls back to direct play, + // where the native player reads the embedded track itself. + final requests = []; + final client = makeClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + 'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001}, + }), + request.method == 'PUT' ? 403 : 200, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final result = await client.buildTranscodeStartPath( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + partId: 99, + selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false), + ); + + expect(result.outcome, TranscodeDecisionOutcome.failed); + expect(result.startPath, isNull); + expect( + requests.where((request) => request.url.path.contains('/transcode/universal')), + isEmpty, + reason: 'no burn may be requested once the target could not be selected', + ); + }); + + test('transcode leaves a real external subtitle file to the sidecar instead of burning it', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: MediaSubtitleTrack( + id: 403, + index: 5, + codec: 'srt', + languageCode: 'swe', + selected: true, + forced: false, + key: '/library/streams/403', + external: true, + ), + ); + + expect(params['subtitles'], 'none'); + expect(params.containsKey('subtitleStreamID'), isFalse); + }); + + test('no burn keeps the per-preset directPlay/directStream pinning', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final originalParams = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.original, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + expect(originalParams['directPlay'], '1'); + expect(originalParams['directStream'], '1'); + + final cappedParams = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + expect(cappedParams['directPlay'], '0'); + expect(cappedParams['directStream'], '0'); + }); + + test('a burn withdraws directPlay, because painting the subtitle in is a re-encode', () { + // Not a preference: a real PMS answers HTTP 400 to `directPlay=1` combined + // with `subtitles=burn`, for text and image subtitles alike. With + // directPlay withdrawn it answers `decision=transcode` on the video stream + // and `decision=burn` on the subtitle. + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + Map paramsFor(String codec, TranscodeQualityPreset preset) => client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: preset, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: codec, selected: true, forced: false), + ); + + for (final codec in ['ass', 'srt', 'pgs']) { + final params = paramsFor(codec, TranscodeQualityPreset.p720_3mbps); + expect(params['subtitles'], 'burn', reason: '$codec should burn'); + expect(params['directPlay'], '0', reason: '$codec burn must not also advertise direct play'); + } + + // Even an original-quality session must drop directPlay once it burns. + final originalBurn = paramsFor('ass', TranscodeQualityPreset.original); + expect(originalBurn['subtitles'], 'burn'); + expect(originalBurn['directPlay'], '0'); + expect(originalBurn['directStream'], '1'); + }); + + test('video transcode requests no subtitles when none is selected, preserving the HLS profile', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); @@ -701,6 +933,7 @@ void main() { ), const Duration(minutes: 8, seconds: 15), ); + expect( PlexClient.transcodeStreamOffsetFromUrl( 'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc', @@ -777,6 +1010,137 @@ void main() { expect(acceptHeaders, everyElement('*/*')); }); + test('an embedded subtitle this server cannot burn falls back as a failure', () async { + // The row has no `key`, so it is embedded and a transcode would have to burn it; `dvb_teletext` + // is not a codec the burn path accepts. Treating that as "no burn requested" sent + // `subtitles=none` with the row still selected, so the caption vanished while state reported it + // active *and* the capped preset was silently retained. + final client = makeClient((request) async { + if (request.url.path == '/library/metadata/42') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'Media': [ + { + 'id': 1, + 'Part': [ + { + 'id': 11, + 'key': '/library/parts/11/file.mkv', + 'Stream': [ + {'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'}, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/video/:/transcode/universal/decision') { + // The server answers direct-play-only, which is what refusing the burn looks like. + return http.Response( + jsonEncode({ + 'MediaContainer': {'generalDecisionCode': 1000, 'transcodeDecisionCode': 1000}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('unexpected request', 500); + }); + addTearDown(client.close); + + final result = await client.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + preferredSubtitleTrack: const SubtitlePreference.track( + SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'), + ), + ), + ); + + expect(result.playMethod, 'DirectPlay', reason: 'direct play is the route that can deliver it'); + expect(result.fallbackReason, isNotNull, reason: 'a dropped burn is a refusal to warn about, not a silent success'); + }); + + test('a valid transcode is refused when it cannot carry the selected subtitle', () async { + // The server happily transcodes the video, but the decision went out as `subtitles=none` because + // `dvb_teletext` cannot be burned. Accepting that stream left the row selected with nothing + // drawing it and no sidecar behind it, so the caption was simply gone. + final client = makeClient((request) async { + if (request.url.path == '/library/metadata/42') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'Media': [ + { + 'id': 1, + 'Part': [ + { + 'id': 11, + 'key': '/library/parts/11/file.mkv', + 'Stream': [ + {'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'}, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/video/:/transcode/universal/decision') { + // 1001 = the server will transcode. Without the refusal this is accepted outright. + return http.Response( + jsonEncode({ + 'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('unexpected request', 500); + }); + addTearDown(client.close); + + final result = await client.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + preferredSubtitleTrack: const SubtitlePreference.track( + SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'), + ), + ), + ); + + expect(result.isTranscoding, isFalse, reason: 'the stream could not carry the caption that was asked for'); + expect(result.playMethod, 'DirectPlay', reason: 'direct play lets the native player read it'); + }); + test('readiness probe target selection walks segment durations to the offset', () { const mediaPlaylist = '#EXTM3U\n' @@ -1052,20 +1416,28 @@ void main() { expect(params['partIndex'], '2'); }); - test('image-based embedded subtitles use the shared container sidecar', () { + test('image-based embedded subtitles are burned rather than sidecarred', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); - final subtitles = buildTranscodeSubtitles(client, [ - MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), - MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false), - ]); + final tracks = [ + MediaSubtitleTrack(id: 401, index: 3, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), + MediaSubtitleTrack(id: 402, index: 4, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false), + ]; - expect(subtitles, hasLength(2)); - expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue); - expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), { - 'https://plex.example.com/video.mkv?X-Plex-Token=token', - }); + expect(buildTranscodeSubtitles(client, tracks), isEmpty); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: client.resolveTranscodeSubtitleTrackForTesting(mediaInfoWithSubtitles(tracks), null), + ); + + expect(params['subtitles'], 'burn'); + expect(params.containsKey('subtitleStreamID'), isFalse); }); group('playback metadata failure contract', () { diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index db8059da..2fae0c39 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -133,6 +133,16 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player { if (selectSubtitleError case final error?) throw error; } + @override + bool get supportsSecondarySubtitles => true; + + final List selectedSecondarySubtitle = []; + + @override + Future selectSecondarySubtitleTrack(SubtitleTrack t) async { + selectedSecondarySubtitle.add(t); + } + @override Future setRate(double rate) async { rates.add(rate); @@ -153,6 +163,8 @@ TrackManager _make({ SubtitleTrack? preferredSubtitleTrack, void Function(String, {Duration? duration})? showMessage, TrackPreferencePersister? persister, + bool primarySubtitleIsServerRendered = false, + SubtitleTrack? preferredSecondarySubtitleTrack, }) { return TrackManager( player: player, @@ -164,6 +176,8 @@ TrackManager _make({ mediaInfo: mediaInfo, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack), + primarySubtitleIsServerRendered: primarySubtitleIsServerRendered, + preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(preferredSecondarySubtitleTrack), showMessage: showMessage, ); } @@ -188,6 +202,17 @@ MediaSourceInfo _metadataFreeDirectMediaInfo({bool selected = true}) { ); } +/// A source with no audio at all: the only shape for which a burned primary and no +/// secondary really means "the catalog is already complete". +MediaSourceInfo _silentBurnedMediaInfo() { + return MediaSourceInfo( + videoUrl: 'https://example.com/video.mp4', + audioTracks: const [], + subtitleTracks: [MediaSubtitleTrack(id: 20, codec: 'ass', selected: true, forced: false)], + chapters: const [], + ); +} + Future _drainAsync() async { for (var i = 0; i < 5; i++) { await Future.delayed(Duration.zero); @@ -383,6 +408,125 @@ void main() { expect(player.selectedSubtitle.single.id, 'no'); }); + test('a server-rendered primary does not wait for a native subtitle track', () async { + // The burned-in case: the row stays advertised and selected, but the picture already carries + // it and the transcode exposes no subtitle track for it. Waiting held audio and rate setup + // for the five-second fallback and then logged a missed deadline twenty-five seconds later. + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + + fakeAsync((async) { + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: 'native-audio', language: 'eng')], + ), + ); + final mgr = _make( + player: player, + mediaInfo: _mediaInfoWithSubtitles(selected: true), + primarySubtitleIsServerRendered: true, + ); + + mgr.applyTrackSelectionWhenReady(); + async.flushMicrotasks(); + + expect(player.rates, [1.5], reason: 'rate setup ran immediately instead of waiting'); + expect( + async.nonPeriodicTimerCount, + 0, + reason: 'no five-second fallback is armed for a track that is never coming', + ); + mgr.dispose(); + }); + }); + + test('a server-rendered primary still waits for a carried secondary', () async { + // Only one selection pass ever runs, so treating a burned primary as "ready" while a carried + // secondary is still in flight retires the subscription and drops the secondary for good. + await SettingsService.getInstance(); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make( + player: player, + mediaInfo: _mediaInfoWithSubtitles(selected: true), + primarySubtitleIsServerRendered: true, + preferredSecondarySubtitleTrack: const SubtitleTrack(id: '10', language: 'eng'), + ); + addTearDown(mgr.dispose); + + mgr.applyTrackSelectionWhenReady(); + await _drainAsync(); + expect(player.selectedSecondarySubtitle, isEmpty, reason: 'the secondary has not arrived yet'); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [SubtitleTrack(id: '10', language: 'eng')], + ), + ); + await _drainAsync(); + + expect( + player.selectedSecondarySubtitle.map((track) => track.id), + ['10'], + reason: 'the wait stayed armed, so the late secondary still landed', + ); + }); + + test('a sidecar-backed secondary source row resolves against its loaded track', () async { + // The realistic Jellyfin transcode shape: the secondary is an embedded row delivered as a + // server-extracted file, so the preference carries a `source:` id while the loaded track is + // external. A `source:` id skips URI matching entirely, so this pins that the source-row + // matcher still pairs the two - if it did not, the carried secondary would wait out the + // deadline and never appear. + await SettingsService.getInstance(); + final player = _FakePlayer( + tracks: const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + ), + ); + final mgr = _make( + player: player, + mediaInfo: _mediaInfoWithSubtitles(selected: true), + primarySubtitleIsServerRendered: true, + preferredSecondarySubtitleTrack: const SubtitleTrack( + id: 'source:10', + uri: 'https://example.com/Subtitles/10/Stream.srt', + language: 'eng', + codec: 'srt', + ), + ); + addTearDown(mgr.dispose); + + mgr.applyTrackSelectionWhenReady(); + await _drainAsync(); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: '1', language: 'eng')], + subtitle: [ + SubtitleTrack( + id: '1', + uri: 'https://example.com/Subtitles/10/Stream.srt', + language: 'eng', + codec: 'srt', + isExternal: true, + ), + ], + ), + ); + await _drainAsync(); + + expect( + player.selectedSecondarySubtitle.map((track) => track.id), + ['1'], + reason: 'the extracted file is the secondary row, however the two sides label it', + ); + }); + test('complete metadata-free direct catalog applies tracks without the five-second fallback', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); @@ -435,6 +579,83 @@ void main() { }); }); + test('a burned primary with no tracks at all is ready immediately', () async { + // A silent video transcoded with its primary burned in exposes neither audio nor subtitle + // tracks. The empty-list guard used to answer first, so selection spent the five-second + // fallback, then a twenty-five-second deadline, on a catalog that was already complete - + // holding the saved playback rate back with it. + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + + fakeAsync((async) { + final player = _FakePlayer(tracks: const Tracks()); + final mgr = _make(player: player, mediaInfo: _silentBurnedMediaInfo(), primarySubtitleIsServerRendered: true); + + mgr.applyTrackSelectionWhenReady(); + async.flushMicrotasks(); + + expect(player.rates, [1.5], reason: 'the rate must not wait for tracks that cannot arrive'); + expect(async.nonPeriodicTimerCount, 0, reason: 'no five-second fallback should be armed'); + mgr.dispose(); + }); + }); + + test('a burned primary with an explicit secondary off is ready immediately too', () async { + // An explicit off is as settled as an absent preference: nothing is wanted, so nothing is + // coming. Treating only null that way left this case in the ten-second track-loading wait. + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + + fakeAsync((async) { + final player = _FakePlayer(tracks: const Tracks()); + final mgr = _make( + player: player, + mediaInfo: _silentBurnedMediaInfo(), + primarySubtitleIsServerRendered: true, + preferredSecondarySubtitleTrack: SubtitleTrack.off, + ); + + mgr.applyTrackSelectionWhenReady(); + async.flushMicrotasks(); + + expect(player.rates, [1.5], reason: 'an explicit off has nothing to wait for either'); + mgr.dispose(); + }); + }); + + test('a burned primary still waits for audio the source advertises', () async { + // The burned subtitle needs no native track, but audio does: answering "ready" on the subtitle + // question alone retired the track listener while the catalog was still filling, and the + // preferred audio track was never selected - playback stayed on the engine's default. + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + + fakeAsync((async) { + final player = _FakePlayer(tracks: const Tracks()); + final mgr = _make( + player: player, + mediaInfo: _metadataFreeDirectMediaInfo(), + primarySubtitleIsServerRendered: true, + preferredAudioTrack: const AudioTrack(id: 'native-audio', language: 'eng'), + ); + + mgr.applyTrackSelectionWhenReady(); + async.flushMicrotasks(); + expect(player.rates, isEmpty, reason: 'the advertised audio track has not arrived yet'); + + player.emitTracks( + const Tracks( + audio: [AudioTrack(id: 'native-audio', language: 'eng')], + ), + ); + async.flushMicrotasks(); + + expect(player.selectedAudio.map((track) => track.id), ['native-audio']); + expect(player.rates, [1.5], reason: 'selection runs once the catalog is complete'); + mgr.dispose(); + }); + }); + test('waits through a partial catalog until the selected Plex subtitle arrives', () async { await SettingsService.getInstance(); final player = _FakePlayer( diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart index b79673ce..a25f6802 100644 --- a/test/utils/codec_utils_test.dart +++ b/test/utils/codec_utils_test.dart @@ -65,6 +65,9 @@ void main() { 'dvdsub', 'vobsub', 'dvb_sub', + // Jellyfin's own spelling. The transcode profile asks it to burn `dvbsub`, so a picker that + // does not recognise the name never offers the track it just negotiated. + 'dvbsub', 'dvb_subtitle', 'PGS', ]) { diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 41a8518e..36a54e30 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -133,15 +133,7 @@ void main() { test('returns the full list unchanged when not transcoding', () { final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')]; - expect( - selectableSourceSubtitleTracks( - tracks, - isTranscoding: false, - sidecarSourceIds: const {}, - supportsEmbeddedTranscodeSelection: false, - ), - tracks, - ); + expect(selectableSourceSubtitleTracks(tracks, isTranscoding: false, sidecarSourceIds: const {}), tracks); }); test('keeps text, image and keyed tracks while transcoding', () { @@ -152,7 +144,6 @@ void main() { [text, image, keyed], isTranscoding: true, sidecarSourceIds: {keyed.id}, - supportsEmbeddedTranscodeSelection: true, ); expect(result, [text, image, keyed]); }); @@ -164,24 +155,47 @@ void main() { [text, unsupported], isTranscoding: true, sidecarSourceIds: const {}, - supportsEmbeddedTranscodeSelection: true, ); expect(result, [text]); }); - test('only offers resolved sidecars when embedded transcode selection is unsupported', () { - final external = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt'); + test('drops a keyed external row whose sidecar never resolved while transcoding', () { + // The client fetches this one itself, and on a transcode an external file is never a burn + // target - so with no sidecar built, nothing can draw it and reloading cannot help. Offering + // it left a selection that silently showed no caption. Its codec is text, which is exactly why + // the codec fallback must not cover keyed rows. + final unresolved = sub(1, codec: 'srt', key: '/library/streams/1'); final embedded = sub(2, codec: 'srt'); - final unavailableExternal = sub(3, codec: 'srt', key: '/missing'); + final result = selectableSourceSubtitleTracks( + [unresolved, embedded], + isTranscoding: true, + sidecarSourceIds: const {}, + ); + expect(result, [embedded], reason: 'the embedded row can still be burned'); + }); + + test('offers a burnable embedded image track that has no sidecar of its own', () { + // The regression this pins: a transcode burns PGS server-side, so the + // track deliberately has no sidecar. Gating selection on a sidecar - or on + // the backend - dropped it from the menu entirely, which is issue #1738's + // "PGS subtitles wont appear at all while transcoding". Every backend can + // be asked to burn, so the codec is the only thing that may exclude it. + final image = sub(1, codec: 'pgssub'); + final result = selectableSourceSubtitleTracks([image], isTranscoding: true, sidecarSourceIds: const {}); + expect(result, [image]); + }); + + test('still offers an unresolved external file only once its sidecar exists', () { + final resolved = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt'); + final unresolved = sub(2, codec: 'weird', key: '/missing'); final result = selectableSourceSubtitleTracks( - [external, embedded, unavailableExternal], + [resolved, unresolved], isTranscoding: true, - sidecarSourceIds: {external.id}, - supportsEmbeddedTranscodeSelection: false, + sidecarSourceIds: {resolved.id}, ); - expect(result, [external]); + expect(result, [resolved], reason: 'an unresolved key with an unburnable codec has no delivery route'); }); test('only offers resolved file sidecars during direct play', () { @@ -193,7 +207,6 @@ void main() { [embedded, availableExternal, unavailableExternal], isTranscoding: false, sidecarSourceIds: {availableExternal.id}, - supportsEmbeddedTranscodeSelection: false, ); expect(result, [embedded, availableExternal]); @@ -213,7 +226,6 @@ void main() { [deliveryExternalEmbedded], isTranscoding: false, sidecarSourceIds: const {}, - supportsEmbeddedTranscodeSelection: false, ); expect(result, [deliveryExternalEmbedded]);