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:
@@ -57,14 +57,10 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
|
||||
final tracks = _currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[];
|
||||
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 <MediaSubtitleTrack>[],
|
||||
isTranscoding: _isTranscoding,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingSpinner() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
if (mediaInfo == null) return const [];
|
||||
if (config.token == null) {
|
||||
|
||||
@@ -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'.
|
||||
|
||||
@@ -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<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
|
||||
ShaderPreset resolveShaderTogglePreset({
|
||||
required ShaderPreset currentPreset,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user