fix(plex): show PGS subtitles when transcoding (#1399)

* fix(plex): show PGS subtitles when transcoding

Bitmap subtitles (PGS/VOBSUB) disappeared from the menu while transcoding on Plex, leaving only "Off". The MKV transcode already supports them, so embed them in the stream like text subs. Now they show up and can be turned on.

* fix(plex): give bitmap subtitle codecs the right sidecar extension

getSubtitleExtension fell back to .srt for vobsub, pgssub and dvb subs. Map them to .sub/.sup so it stays consistent with isImageSubtitleCodec and any sidecar URL or download uses the correct extension.
This commit is contained in:
Huzama Ahmad
2026-06-25 10:50:38 +02:00
committed by GitHub
parent 791433bceb
commit 7b917462c7
8 changed files with 161 additions and 25 deletions
+4 -8
View File
@@ -57,14 +57,10 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
} }
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() { List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
final tracks = _currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[]; return selectableSourceSubtitleTracks(
if (!_isTranscoding) return tracks; _currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
return tracks isTranscoding: _isTranscoding,
.where((track) { );
final hasKey = track.key != null && track.key!.isNotEmpty;
return hasKey || CodecUtils.isTextSubtitleCodec(track.codec);
})
.toList(growable: false);
} }
Widget _buildLoadingSpinner() { Widget _buildLoadingSpinner() {
-1
View File
@@ -66,7 +66,6 @@ import '../services/shader_service.dart';
import '../providers/shader_provider.dart'; import '../providers/shader_provider.dart';
import '../providers/user_profile_provider.dart'; import '../providers/user_profile_provider.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/codec_utils.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/log_redaction_manager.dart'; import '../utils/log_redaction_manager.dart';
import '../utils/live_tv_player_navigation.dart'; import '../utils/live_tv_player_navigation.dart';
+15 -10
View File
@@ -3048,9 +3048,13 @@ class PlexClient
int? offsetMs, int? offsetMs,
}) { }) {
final isOriginal = preset.isOriginal; final isOriginal = preset.isOriginal;
final selectedEmbeddedTextSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack) final selectedEmbeddedSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack)
? selectedSubtitleTrack ? selectedSubtitleTrack
: null; : 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. // Build the client profile from scratch via X-Plex-Client-Profile-Extra.
// We use the `Generic` base platform (see [_transcodePlatformName]) which // We use the `Generic` base platform (see [_transcodePlatformName]) which
@@ -3104,12 +3108,13 @@ class PlexClient
'directStreamAudio': '0', 'directStreamAudio': '0',
'mediaBufferSize': '102400', 'mediaBufferSize': '102400',
'session': transcodeSessionId, 'session': transcodeSessionId,
// Embed selected text subtitles in the MKV stream. Bitmap subtitles and // Embed the selected subtitle in the MKV stream: text codecs are
// unselected tracks stay at `none` so the server cannot burn them into // converted to text, image codecs (PGS/VOBSUB) are copied as-is and
// the video. // rendered by the player — never burned into the video. Unselected tracks
'subtitles': selectedEmbeddedTextSubtitle != null ? 'embedded' : 'none', // and keyed sidecars stay at `none`.
if (selectedEmbeddedTextSubtitle != null) 'subtitleStreamID': selectedEmbeddedTextSubtitle.id.toString(), 'subtitles': selectedEmbeddedSubtitle != null ? 'embedded' : 'none',
if (selectedEmbeddedTextSubtitle != null) 'advancedSubtitles': 'text', if (selectedEmbeddedSubtitle != null) 'subtitleStreamID': selectedEmbeddedSubtitle.id.toString(),
if (embedSubtitleAsText) 'advancedSubtitles': 'text',
// Preserve source timestamps for the HTTP/MKV stream so player seeks and // Preserve source timestamps for the HTTP/MKV stream so player seeks and
// sidecar subtitles stay aligned with Plex source time. // sidecar subtitles stay aligned with Plex source time.
'copyts': '1', 'copyts': '1',
@@ -3504,7 +3509,7 @@ class PlexClient
bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) { bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) {
if (track == null) return false; if (track == null) return false;
if (track.key != null && track.key!.isNotEmpty) 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) { SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
@@ -3521,8 +3526,8 @@ class PlexClient
} }
/// Build subtitle sidecars for Plex transcode playback. Only real keyed /// Build subtitle sidecars for Plex transcode playback. Only real keyed
/// sidecars are loaded externally; selected embedded text subtitles are /// sidecars are loaded externally; selected embedded subtitles (text or
/// carried by the main HTTP/MKV stream. /// image) are carried by the main HTTP/MKV stream.
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const []; if (mediaInfo == null) return const [];
if (config.token == null) { if (config.token == null) {
+29
View File
@@ -21,10 +21,14 @@ class CodecUtils {
case 'mov_text': case 'mov_text':
return 'srt'; return 'srt';
case 'pgs': case 'pgs':
case 'pgssub':
case 'hdmv_pgs_subtitle': case 'hdmv_pgs_subtitle':
return 'sup'; return 'sup';
case 'dvd_subtitle': case 'dvd_subtitle':
case 'dvdsub': case 'dvdsub':
case 'vobsub':
case 'dvb_sub':
case 'dvb_subtitle':
return 'sub'; return 'sub';
default: default:
return 'srt'; 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. /// Formats a subtitle codec name to a user-friendly display format.
/// ///
/// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'. /// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'.
@@ -52,6 +52,7 @@ import '../../services/keyboard_shortcuts_service.dart';
import '../../services/device_adjustment_service.dart'; import '../../services/device_adjustment_service.dart';
import '../../services/scrub_preview_source.dart'; import '../../services/scrub_preview_source.dart';
import '../../services/settings_service.dart'; import '../../services/settings_service.dart';
import '../../utils/codec_utils.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
import '../../utils/player_utils.dart'; import '../../utils/player_utils.dart';
@@ -92,6 +93,26 @@ part 'parts/playback_input.dart';
part 'parts/track_controls.dart'; part 'parts/track_controls.dart';
part 'parts/visibility.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<MediaSubtitleTrack> selectableSourceSubtitleTracks(
List<MediaSubtitleTrack> 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 @visibleForTesting
ShaderPreset resolveShaderTogglePreset({ ShaderPreset resolveShaderTogglePreset({
required ShaderPreset currentPreset, required ShaderPreset currentPreset,
@@ -460,7 +460,7 @@ void main() {
expect(params['partIndex'], '2'); 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)); final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close); 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['protocol'], 'http');
expect(params.containsKey('subtitleStreamID'), isFalse);
expect(params.containsKey('advancedSubtitles'), isFalse); expect(params.containsKey('advancedSubtitles'), isFalse);
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile'))); 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)); final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close); 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, [ final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false), MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false), MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),
+59 -2
View File
@@ -26,14 +26,33 @@ void main() {
expect(CodecUtils.getSubtitleExtension('mov_text'), 'srt'); 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('pgs'), 'sup');
expect(CodecUtils.getSubtitleExtension('pgssub'), 'sup');
expect(CodecUtils.getSubtitleExtension('HDMV_PGS_SUBTITLE'), '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('dvd_subtitle'), 'sub');
expect(CodecUtils.getSubtitleExtension('dvdsub'), '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', () { 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', () { group('CodecUtils.formatSubtitleCodec', () {
test('maps known codecs to friendly labels', () { test('maps known codecs to friendly labels', () {
expect(CodecUtils.formatSubtitleCodec('subrip'), 'SRT'); expect(CodecUtils.formatSubtitleCodec('subrip'), 'SRT');
+25
View File
@@ -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', () { group('shouldShowSkipMarkerButton', () {
test('does not show before the first frame is rendered', () { test('does not show before the first frame is rendered', () {
expect( expect(