diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 10343f7a..0635238a 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -57,14 +57,10 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { } List _sourceSubtitleTracksForControls() { - final tracks = _currentMediaInfo?.subtitleTracks ?? const []; - if (!_isTranscoding) return tracks; - return tracks - .where((track) { - final hasKey = track.key != null && track.key!.isNotEmpty; - return hasKey || CodecUtils.isTextSubtitleCodec(track.codec); - }) - .toList(growable: false); + return selectableSourceSubtitleTracks( + _currentMediaInfo?.subtitleTracks ?? const [], + isTranscoding: _isTranscoding, + ); } Widget _buildLoadingSpinner() { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 3ee82967..a597b74a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -66,7 +66,6 @@ import '../services/shader_service.dart'; import '../providers/shader_provider.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; -import '../utils/codec_utils.dart'; import '../utils/dialogs.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/live_tv_player_navigation.dart'; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 75f76ff9..370cd2e0 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3048,9 +3048,13 @@ class PlexClient int? offsetMs, }) { final isOriginal = preset.isOriginal; - final selectedEmbeddedTextSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack) + final selectedEmbeddedSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack) ? selectedSubtitleTrack : null; + // Only text subtitles get `advancedSubtitles=text`; image subtitles + // (PGS/VOBSUB) are copied into the MKV as-is for the player to render. + final embedSubtitleAsText = + selectedEmbeddedSubtitle != null && _canTranscodeSubtitleAsText(selectedEmbeddedSubtitle); // Build the client profile from scratch via X-Plex-Client-Profile-Extra. // We use the `Generic` base platform (see [_transcodePlatformName]) which @@ -3104,12 +3108,13 @@ class PlexClient 'directStreamAudio': '0', 'mediaBufferSize': '102400', 'session': transcodeSessionId, - // Embed selected text subtitles in the MKV stream. Bitmap subtitles and - // unselected tracks stay at `none` so the server cannot burn them into - // the video. - 'subtitles': selectedEmbeddedTextSubtitle != null ? 'embedded' : 'none', - if (selectedEmbeddedTextSubtitle != null) 'subtitleStreamID': selectedEmbeddedTextSubtitle.id.toString(), - if (selectedEmbeddedTextSubtitle != null) 'advancedSubtitles': 'text', + // Embed the selected subtitle in the MKV stream: text codecs are + // converted to text, image codecs (PGS/VOBSUB) are copied as-is and + // rendered by the player — never burned into the video. Unselected tracks + // and keyed sidecars stay at `none`. + 'subtitles': selectedEmbeddedSubtitle != null ? 'embedded' : 'none', + if (selectedEmbeddedSubtitle != null) 'subtitleStreamID': selectedEmbeddedSubtitle.id.toString(), + if (embedSubtitleAsText) 'advancedSubtitles': 'text', // Preserve source timestamps for the HTTP/MKV stream so player seeks and // sidecar subtitles stay aligned with Plex source time. 'copyts': '1', @@ -3504,7 +3509,7 @@ class PlexClient bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) { if (track == null) return false; if (track.key != null && track.key!.isNotEmpty) return false; - return _canTranscodeSubtitleAsText(track); + return CodecUtils.isEmbeddableSubtitleCodec(track.codec); } SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) { @@ -3521,8 +3526,8 @@ class PlexClient } /// Build subtitle sidecars for Plex transcode playback. Only real keyed - /// sidecars are loaded externally; selected embedded text subtitles are - /// carried by the main HTTP/MKV stream. + /// sidecars are loaded externally; selected embedded subtitles (text or + /// image) are carried by the main HTTP/MKV stream. List _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { if (mediaInfo == null) return const []; if (config.token == null) { diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index 5db8f758..e61c38b7 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -21,10 +21,14 @@ class CodecUtils { case 'mov_text': return 'srt'; case 'pgs': + case 'pgssub': case 'hdmv_pgs_subtitle': return 'sup'; case 'dvd_subtitle': case 'dvdsub': + case 'vobsub': + case 'dvb_sub': + case 'dvb_subtitle': return 'sub'; default: return 'srt'; @@ -39,6 +43,31 @@ class CodecUtils { }; } + /// Image-based (bitmap) subtitle codecs. These can't be converted to text; + /// during an HTTP/MKV transcode Plex copies the stream into the container and + /// the player renders it natively. + static bool isImageSubtitleCodec(String? codec) { + if (codec == null) return false; + return switch (codec.toLowerCase()) { + 'pgs' || + 'pgssub' || + 'hdmv_pgs_subtitle' || + 'dvd_subtitle' || + 'dvdsub' || + 'vobsub' || + 'dvb_sub' || + 'dvb_subtitle' => true, + _ => false, + }; + } + + /// Subtitle codecs that can be carried inside the HTTP/MKV transcode stream + /// (`subtitles=embedded`): text codecs plus the image codecs the MKV target + /// supports. Keyed sidecars are delivered separately and are not covered here. + static bool isEmbeddableSubtitleCodec(String? codec) { + return isTextSubtitleCodec(codec) || isImageSubtitleCodec(codec); + } + /// Formats a subtitle codec name to a user-friendly display format. /// /// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'. diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 269615a7..ad53d21c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -52,6 +52,7 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/device_adjustment_service.dart'; import '../../services/scrub_preview_source.dart'; import '../../services/settings_service.dart'; +import '../../utils/codec_utils.dart'; import '../../utils/formatters.dart'; import '../../utils/platform_detector.dart'; import '../../utils/player_utils.dart'; @@ -92,6 +93,26 @@ part 'parts/playback_input.dart'; part 'parts/track_controls.dart'; part 'parts/visibility.dart'; +/// Subtitle tracks offered in the player's "source" subtitle list. +/// +/// While transcoding, only tracks the HTTP/MKV stream can actually deliver are +/// shown: keyed sidecars plus any codec the transcode can embed (text or +/// image — see [CodecUtils.isEmbeddableSubtitleCodec]). Outside transcode the +/// full list is returned unchanged, since the player has direct access to every +/// embedded stream. +List selectableSourceSubtitleTracks( + List tracks, { + required bool isTranscoding, +}) { + if (!isTranscoding) return tracks; + return tracks + .where((track) { + final hasKey = track.key != null && track.key!.isNotEmpty; + return hasKey || CodecUtils.isEmbeddableSubtitleCodec(track.codec); + }) + .toList(growable: false); +} + @visibleForTesting ShaderPreset resolveShaderTogglePreset({ required ShaderPreset currentPreset, diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index c9c7e4af..cce4681d 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -460,7 +460,7 @@ void main() { expect(params['partIndex'], '2'); }); - test('unsupported embedded subtitles keep main transcode subtitles disabled', () { + test('selected image-based subtitles are embedded in HTTP MKV transcode without advancedSubtitles', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); @@ -479,17 +479,21 @@ void main() { ), ); - expect(params['subtitles'], 'none'); + // PGS is copied into the MKV as a stream; `advancedSubtitles=text` is + // text-only and must be absent so the server doesn't try to convert it. + expect(params['subtitles'], 'embedded'); + expect(params['subtitleStreamID'], '401'); expect(params['protocol'], 'http'); - expect(params.containsKey('subtitleStreamID'), isFalse); expect(params.containsKey('advancedSubtitles'), isFalse); expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile'))); }); - test('bitmap embedded subtitles are skipped during transcode instead of burned', () { + test('image-based embedded subtitles are carried in the MKV, not as sidecars', () { final client = makeClient((_) async => http.Response('not used', 500)); addTearDown(client.close); + // Embedded bitmap streams have no Plex `key`, so there is no sidecar URL to + // build — they ride the main HTTP/MKV stream via `subtitles=embedded`. final subtitles = buildTranscodeSubtitles(client, [ MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false), diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart index cbb6e290..221a7b65 100644 --- a/test/utils/codec_utils_test.dart +++ b/test/utils/codec_utils_test.dart @@ -26,14 +26,33 @@ void main() { expect(CodecUtils.getSubtitleExtension('mov_text'), 'srt'); }); - test('maps pgs/hdmv_pgs_subtitle -> sup', () { + test('maps pgs codecs -> sup', () { expect(CodecUtils.getSubtitleExtension('pgs'), 'sup'); + expect(CodecUtils.getSubtitleExtension('pgssub'), 'sup'); expect(CodecUtils.getSubtitleExtension('HDMV_PGS_SUBTITLE'), 'sup'); }); - test('maps dvd_subtitle/dvdsub -> sub', () { + test('maps dvd/vobsub/dvb bitmap codecs -> sub', () { expect(CodecUtils.getSubtitleExtension('dvd_subtitle'), 'sub'); expect(CodecUtils.getSubtitleExtension('dvdsub'), 'sub'); + expect(CodecUtils.getSubtitleExtension('vobsub'), 'sub'); + expect(CodecUtils.getSubtitleExtension('dvb_sub'), 'sub'); + expect(CodecUtils.getSubtitleExtension('dvb_subtitle'), 'sub'); + }); + + test('every image subtitle codec maps to a non-srt extension', () { + for (final codec in [ + 'pgs', + 'pgssub', + 'hdmv_pgs_subtitle', + 'dvd_subtitle', + 'dvdsub', + 'vobsub', + 'dvb_sub', + 'dvb_subtitle', + ]) { + expect(CodecUtils.getSubtitleExtension(codec), isNot('srt'), reason: codec); + } }); test('defaults to srt for unknown codec', () { @@ -42,6 +61,44 @@ void main() { }); }); + group('CodecUtils subtitle classification', () { + test('isTextSubtitleCodec recognizes text codecs only', () { + for (final codec in ['srt', 'subrip', 'ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'ASS']) { + expect(CodecUtils.isTextSubtitleCodec(codec), isTrue, reason: codec); + } + for (final codec in ['pgs', 'dvd_subtitle', 'vobsub', 'weird', null]) { + expect(CodecUtils.isTextSubtitleCodec(codec), isFalse, reason: '$codec'); + } + }); + + test('isImageSubtitleCodec recognizes bitmap codecs only', () { + for (final codec in [ + 'pgs', + 'pgssub', + 'hdmv_pgs_subtitle', + 'dvd_subtitle', + 'dvdsub', + 'vobsub', + 'dvb_sub', + 'dvb_subtitle', + 'PGS', + ]) { + expect(CodecUtils.isImageSubtitleCodec(codec), isTrue, reason: codec); + } + for (final codec in ['srt', 'ass', 'mov_text', 'weird', null]) { + expect(CodecUtils.isImageSubtitleCodec(codec), isFalse, reason: '$codec'); + } + }); + + test('isEmbeddableSubtitleCodec covers text and image, not unknown', () { + for (final codec in ['srt', 'ass', 'pgs', 'vobsub', 'dvd_subtitle']) { + expect(CodecUtils.isEmbeddableSubtitleCodec(codec), isTrue, reason: codec); + } + expect(CodecUtils.isEmbeddableSubtitleCodec('weird'), isFalse); + expect(CodecUtils.isEmbeddableSubtitleCodec(null), isFalse); + }); + }); + group('CodecUtils.formatSubtitleCodec', () { test('maps known codecs to friendly labels', () { expect(CodecUtils.formatSubtitleCodec('subrip'), 'SRT'); diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index ac6bd398..996cc701 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -122,6 +122,31 @@ void main() { }); }); + group('selectableSourceSubtitleTracks', () { + MediaSubtitleTrack sub(int id, {String? codec, String? key}) => + MediaSubtitleTrack(id: id, codec: codec, key: key, languageCode: 'eng', selected: false, forced: false); + + 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), same(tracks)); + }); + + test('keeps text, image and keyed tracks while transcoding', () { + final text = sub(1, codec: 'srt'); + final image = sub(2, codec: 'pgs'); + final keyed = sub(3, codec: 'weird', key: '/library/streams/3'); + final result = selectableSourceSubtitleTracks([text, image, keyed], isTranscoding: true); + expect(result, [text, image, keyed]); + }); + + test('drops non-keyed unsupported codecs while transcoding', () { + final text = sub(1, codec: 'ass'); + final unsupported = sub(2, codec: 'weird'); + final result = selectableSourceSubtitleTracks([text, unsupported], isTranscoding: true); + expect(result, [text]); + }); + }); + group('shouldShowSkipMarkerButton', () { test('does not show before the first frame is rendered', () { expect(