feat(plex): migrate video transcoding to HLS

This commit is contained in:
edde746
2026-07-14 18:11:04 +02:00
parent 9d812fa3cc
commit abf1027b77
17 changed files with 201 additions and 550 deletions
@@ -136,7 +136,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
int? newSubtitleStreamId,
}) async {
final currentPlayer = player;
if (!mounted || currentPlayer == null || _playbackTransition != _PlaybackTransition.idle) return;
if (!mounted || currentPlayer == null || _playbackTransition != _PlaybackTransition.idle) {
return;
}
final effectiveMediaIndex = newMediaIndex ?? _effectiveSelectedMediaIndex;
final effectivePreset = newPreset ?? _selectedQualityPreset;
@@ -153,7 +155,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final isPresetChange = effectivePreset != _selectedQualityPreset;
final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId;
final isSubtitleChange = newSubtitleStreamId != null && effectiveSubtitleStreamId != currentSubtitleStreamId;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) return;
if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) {
return;
}
// Read the client before any await — context across an async gap. A
// missing client leaves this null and the guard below reports it.
@@ -392,7 +396,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
hasVideoUrl: true,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
);
if (frameRatePlan == null || !isCurrentReload()) return _MediaReloadOutcome.superseded;
if (frameRatePlan == null || !isCurrentReload()) {
return _MediaReloadOutcome.superseded;
}
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) _frameRate.applied = true;
@@ -404,7 +410,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
);
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final openTiming = _playbackOpenTiming(
backend: metadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: openResumePosition,
durationMs: metadata.durationMs,
@@ -33,7 +33,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
final playbackState = context.read<PlaybackStateProvider>();
final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive;
final canSeek = !widget.isLive && (currentPlayer.state.seekable || _usesPlexVodTranscodeSeekPolicy);
final canSeek = !widget.isLive && currentPlayer.state.seekable;
if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return;
@@ -61,11 +61,10 @@ class _ExternalSubtitleOpenPlan {
/// Shared building blocks for opening media on the live player.
///
/// The initial start flow ([_startPlayback]), the in-place reload flow
/// ([_reloadMediaInPlace]), and the transcode-restart seek
/// ([_restartPlexTranscodeAt]) all route through these helpers so per-open
/// The initial start flow ([_startPlayback]) and in-place reload flow
/// ([_reloadMediaInPlace]) both route through these helpers so per-open
/// behavior (display priming, frame-rate suppression windows, native
/// subtitle styling, the open sequence itself) cannot drift between paths.
/// subtitle styling, and the open sequence) cannot drift between paths.
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
@@ -537,8 +536,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
}
/// Open [videoUrl] on [player]: stream tuning + force-seekable hint →
/// open → native subtitle style.
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
///
/// [shouldContinue] is re-checked between the awaits so stale generations
/// stop without touching the player further. [onOpened] fires immediately
@@ -567,10 +565,6 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
isTranscoding: isTranscoding,
selectedVersion: selectedVersion,
);
// Transcode streams can be seekable even when MPV cannot prove it
// from response headers. Reset non-transcodes so live/direct/offline
// streams keep native seekability detection.
await player.setProperty('force-seekable', isTranscoding ? 'yes' : 'no');
if (shouldContinue != null && !shouldContinue()) return false;
await player.open(
Media(videoUrl, start: timing.mediaStart, headers: headers),
@@ -54,7 +54,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Build the stream URL (with optional offset for time-shift)
final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds);
if (streamUrl == null || !mounted) throw Exception('Failed to build stream path');
if (streamUrl == null || !mounted) {
throw Exception('Failed to build stream path');
}
// Track stream start epoch for position calculations
if (offsetSeconds != null) {
@@ -228,7 +230,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// so tracks are discovered in a single prepare/loadfile cycle. Any
// backend that cannot do that still uses the post-open sub-add path.
final openTiming = _playbackOpenTiming(
backend: _currentMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
+1 -156
View File
@@ -12,12 +12,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
await _retrySpuriousEofRecovery(reason: 'seek', resumePosition: target);
return;
}
if (_plexTranscodeSeekAction(currentPlayer, target) == PlexTranscodeSeekAction.nativeSeek) {
await currentPlayer.seek(target);
return;
}
await _restartPlexTranscodeAt(target);
await currentPlayer.seek(target);
}
/// Relative seek shared by the companion remote and the OS media-control
@@ -31,154 +26,4 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
}
await _seekPlayback(currentPlayer.state.position + delta);
}
bool get _usesPlexVodTranscodeSeekPolicy {
return _isTranscoding &&
!widget.isLive &&
!_isOfflinePlayback &&
_currentMetadata.backend == MediaBackend.plex &&
_selectedQualityPreset != TranscodeQualityPreset.original;
}
PlexTranscodeSeekAction _plexTranscodeSeekAction(Player currentPlayer, Duration target) {
if (!_usesPlexVodTranscodeSeekPolicy) return PlexTranscodeSeekAction.nativeSeek;
final state = currentPlayer.state;
final action = resolvePlexTranscodeSeekAction(
currentPosition: state.position,
target: target,
bufferRanges: state.bufferRanges,
allowBufferedNativeSeek: _playerBackendLabel == 'mpv',
);
appLogger.d(
'Plex transcode seek decision: action=${action.name}, '
'position=${state.position.inSeconds}s, target=${target.inSeconds}s, '
'buffer=${state.buffer.inSeconds}s, ranges=${state.bufferRanges.length}',
);
return action;
}
Future<void> _restartPlexTranscodeAt(Duration target) async {
if (_playbackTransition != _PlaybackTransition.idle) return;
appLogger.d('Restarting Plex transcode at ${target.inSeconds}s');
_playbackTransition = _PlaybackTransition.restartingTranscode;
_chromeController.show();
final currentPlayer = player;
if (currentPlayer == null) {
_playbackTransition = _PlaybackTransition.idle;
return;
}
final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds);
final shouldResumePlayback = _playbackIntentShouldPlay;
final offlineWatchService = context.read<OfflineWatchSyncService>();
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
try {
final playbackContext = await playbackResolver.resolve(
metadata: replacementMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
// A transcode restart must stay on the server stream even when the
// preset would normally prefer a downloaded copy.
preferOffline: false,
);
if (!mounted || player != currentPlayer) return;
final result = playbackContext.result;
if (result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
final session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
);
final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
);
final shouldAutoPlay = shouldResumePlayback && externalSubtitlePlan.canStartBeforeTrackSetup;
final didOpen = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: SettingsService.instance,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
isLocalMedia: result.usesLocalMedia,
selectedVersion: result.selectedVersion,
timing: _playbackOpenTiming(
backend: replacementMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: target,
durationMs: replacementMetadata.durationMs,
),
headers: playbackContext.streamHeaders,
play: shouldAutoPlay,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => mounted && player == currentPlayer,
onOpened: () {
// A pre-open failure leaves the previous session (and ids)
// committed; the swap happens only once the player owns the
// restarted stream.
_currentMetadata = replacementMetadata;
_commitPlaybackSession(session);
},
);
if (!didOpen || !mounted || player != currentPlayer) return;
_setPlayerState(() {});
// The play session changed with the restarted transcode — rebind the
// progress tracker so reports don't keep flowing against the dead
// session ids. The item itself is unchanged, so the item-keyed
// services (media-controls metadata, scrobblers) stay as they are.
_progressTracker?.stopTracking();
_progressTracker?.dispose();
_progressTracker = null;
_rebindProgressTracker(
metadata: _currentMetadata,
mediaClient: session.reportingClient,
offlineWatchService: offlineWatchService,
playSessionId: _playbackPlaySessionId,
playMethod: _playbackPlayMethod,
mediaInfo: _currentMediaInfo,
);
final trackManager = _trackManager;
if (trackManager != null) {
trackManager.metadata = _currentMetadata;
trackManager.mediaInfo = _currentMediaInfo;
trackManager.cacheExternalSubtitles(result.externalSubtitles);
await _applyTracksAfterOpen(
trackManager: trackManager,
externalSubtitlePlan: externalSubtitlePlan,
// A restart while paused must stay paused — selection is still
// applied through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () => shouldResumePlayback && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: true,
);
}
_updateMediaControlsPlaybackState();
} catch (e, st) {
appLogger.w('Failed to restart Plex transcode at ${target.inSeconds}s', error: e, stackTrace: st);
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
} finally {
_playbackTransition = _PlaybackTransition.idle;
}
}
}
+8 -12
View File
@@ -133,7 +133,7 @@ Future<void> _setWakelock(bool enabled) async {
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
/// transition is in flight.
enum _PlaybackTransition { idle, reloadingMedia, restartingTranscode, switchingChannel }
enum _PlaybackTransition { idle, reloadingMedia, switchingChannel }
/// Outcome of [VideoPlayerScreenState._reloadMediaInPlace].
enum _MediaReloadOutcome {
@@ -156,10 +156,9 @@ enum _MediaReloadOutcome {
failed,
}
/// Handle for one playback attempt (initial start, in-place reload,
/// transcode restart). Async continuations check [isCurrent] after every
/// await: it holds while the screen is mounted, the captured player is
/// still the active one, and no newer attempt has bumped the generation.
/// Handle for one playback attempt (initial start or in-place reload).
/// Async continuations check [isCurrent] after every await while the screen
/// is mounted, the captured player is active, and no newer attempt exists.
class _PlaybackAttempt {
_PlaybackAttempt._(this._owner, this.generation, this.player);
@@ -179,15 +178,13 @@ class _PlaybackOpenTiming {
}
_PlaybackOpenTiming _playbackOpenTiming({
required MediaBackend backend,
required bool isTranscoding,
required Duration? resumePosition,
required int? durationMs,
}) {
final usesSourceOffsetTranscode = isTranscoding && backend == MediaBackend.plex;
return _PlaybackOpenTiming(
mediaStart: usesSourceOffsetTranscode ? null : resumePosition,
timelineOffset: usesSourceOffsetTranscode ? resumePosition ?? Duration.zero : Duration.zero,
mediaStart: resumePosition,
timelineOffset: Duration.zero,
timelineDuration: isTranscoding && durationMs != null ? Duration(milliseconds: durationMs) : null,
);
}
@@ -281,9 +278,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isLoadingNext = false;
bool _isLoadingPrevious = false;
// In-flight media-source transition. At most one can run at a time: the
// entry guards make reload / transcode-restart / channel-switch mutually
// exclusive instead of relying on three independent booleans.
// In-flight media-source transition. At most one can run at a time: reloads
// and channel switches are mutually exclusive.
_PlaybackTransition _playbackTransition = _PlaybackTransition.idle;
bool _playbackIntentShouldPlay = true;
+70 -92
View File
@@ -81,6 +81,28 @@ part 'plex_client/parts/collections.dart';
part 'plex_client/parts/play_queues.dart';
part 'plex_client/parts/metadata_edit.dart';
const _plexHlsVideoTranscodeTarget =
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
const _plexHlsSubtitleTranscodeTarget =
'add-transcode-target(type=subtitleProfile&context=streaming'
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)';
String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
final clauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
if (maxVideoBitrateKbps != null) {
clauses.add(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=$maxVideoBitrateKbps&replace=true)',
);
}
clauses
..add(_plexHlsVideoTranscodeTarget)
..add(_plexHlsSubtitleTranscodeTarget);
return clauses.join('+');
}
/// Result of a paginated library content fetch
class _LibraryContentResult {
final List<PlexMetadataDto> items;
@@ -821,7 +843,9 @@ class PlexClient
final response = await _getWithFailover('/', timeout: MediaServerTimeouts.plexProbe);
return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline;
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
if (e.statusCode == 401 || e.statusCode == 403) {
return HealthStatus.authError;
}
return HealthStatus.offline;
} catch (_) {
return HealthStatus.offline;
@@ -2324,12 +2348,11 @@ class PlexClient
}
}
/// Build a VOD transcode stream URL (decision + start path).
/// Build an HLS VOD transcode stream URL (decision + start path).
///
/// Mirrors the live tune's _buildLiveStreamPath but for on-demand video with a quality
/// preset, selected audio stream, and Plex Desktop-style HTTP/MKV output.
/// Text subtitles selected on the Plex part are embedded in the MKV stream;
/// real external sidecars are still attached separately by callers.
/// Text subtitles selected on the Plex part are segmented as WebVTT,
/// image subtitles are burned because HLS has no bitmap subtitle rendition,
/// and real external sidecars are still attached separately by callers.
///
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
/// seeks + quality/version/audio switches within one playback so the
@@ -2343,7 +2366,6 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) async {
try {
final allParams = _buildTranscodeParams(
@@ -2355,7 +2377,6 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: offsetMs,
);
return await _runTranscodeDecision(
startEndpoint: _videoTranscodeStartEndpoint,
@@ -2372,8 +2393,7 @@ class PlexClient
///
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
/// decision → start handshake against `/music/:/transcode/universal`,
/// with a bitrate-capped MP3 target instead of the video HTTP/MKV
/// target. No subtitle/copyts params — those are video-only.
/// with a bitrate-capped HTTP/MP3 target instead of segmented video HLS.
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildMusicTranscodeStartPath({
required String ratingKey,
required int mediaIndex,
@@ -2402,15 +2422,14 @@ class PlexClient
}
}
static const String _videoTranscodeStartEndpoint = '/video/:/transcode/universal/start';
static const String _videoTranscodeStartEndpoint = '/video/:/transcode/universal/start.m3u8';
static const String _musicTranscodeStartEndpoint = '/music/:/transcode/universal/start.mp3';
/// Shared decision plumbing for the video and music transcode flows: GET
/// the sibling `decision` endpoint with the exact start params, parse the
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
/// path (token stripped) on success. [startEndpoint] is the start path the
/// stream will use, including any container extension (`start` /
/// `start.mp3`).
/// path (token stripped) on success. [startEndpoint] includes the container
/// extension (`start.m3u8` / `start.mp3`).
Future<({String? startPath, TranscodeDecisionOutcome outcome})> _runTranscodeDecision({
required String startEndpoint,
required Map<String, String> allParams,
@@ -2476,57 +2495,22 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) {
final isOriginal = preset.isOriginal;
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
// has no pre-installed transcode targets, so we must `add-transcode-target`
// rather than `append-transcode-target-codec` (which only edits existing
// targets — empty on Generic, hence Plex returned decision code 2000
// "neither direct play nor conversion is available").
//
// For non-original presets we also add a bitrate limitation that caps
// the video codec; with `replace=true` it overrides any default limit.
//
// See openapi.md §"Profile Augmentations" for the DSL reference.
final profileExtraClauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
if (!isOriginal && preset.videoBitrateKbps != null) {
profileExtraClauses.add(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
);
}
// Match Plex Desktop's stable HTTP/MKV transcode target. Codec-list commas
// are pre-encoded as `%2C` — see the profile-extra encoding note above.
profileExtraClauses.add(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
final segmentSubtitle = selectedInternalSubtitle != null && _canTranscodeSubtitleAsText(selectedInternalSubtitle);
final burnSubtitle =
selectedInternalSubtitle != null && CodecUtils.isImageSubtitleCodec(selectedInternalSubtitle.codec);
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
);
profileExtraClauses.add(
'add-transcode-target-settings(type=videoProfile&context=streaming'
'&protocol=http&CopyMatroskaAttachments=true)',
);
final clientProfileExtra = profileExtraClauses.join('+');
// HTTP/MKV matches Plex Desktop and lets MPV see embedded subtitle streams.
// HLS `subtitles=segmented` was accepted by Plex but produced manifests
// with only video/audio renditions for MPV.
return <String, String>{
'hasMDE': '1',
'path': '/library/metadata/$ratingKey',
'mediaIndex': mediaIndex.toString(),
'partIndex': partIndex.toString(),
'protocol': 'http',
'protocol': 'hls',
'fastSeek': '1',
'directPlay': isOriginal ? '1' : '0',
'directStream': isOriginal ? '1' : '0',
@@ -2539,36 +2523,28 @@ class PlexClient
'directStreamAudio': '0',
'mediaBufferSize': '102400',
'session': transcodeSessionId,
// 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',
'subtitles': segmentSubtitle
? 'segmented'
: burnSubtitle
? 'burn'
: 'none',
if (selectedInternalSubtitle != null) 'subtitleStreamID': selectedInternalSubtitle.id.toString(),
if (segmentSubtitle) 'advancedSubtitles': 'text',
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': clientProfileExtra,
'X-Plex-Chunked': '1',
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'standalone',
'X-Plex-Language': 'en',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
// Plex's server rejects unknown platform names with HTTP 400 and maps
// known names to codec/bitrate base profiles. Our usual "Flutter"
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
// Plex-recognized name just for transcode requests. See
// [_transcodePlatformName] for the mapping.
'X-Plex-Platform': _transcodePlatformName(),
'X-Plex-Client-Profile-Name': 'Generic',
if (config.device != null) 'X-Plex-Device': config.device!,
if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!,
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
}
@@ -2583,7 +2559,6 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) {
return _buildTranscodeParams(
ratingKey: ratingKey,
@@ -2594,7 +2569,6 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: offsetMs,
);
}
@@ -2940,7 +2914,9 @@ class PlexClient
if (streams is! List) continue;
for (final stream in streams) {
if (stream is! Map) continue;
if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) continue;
if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) {
continue;
}
final key = stream['key'] as String?;
if (key == null || key.isEmpty) continue;
final format = ((stream['format'] ?? stream['codec']) as String?)?.toLowerCase();
@@ -2954,10 +2930,9 @@ class PlexClient
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
/// then either runs the transcode-decision flow or returns the direct-play
/// URL. External subtitle tracks are absolutized with the server's auth
/// token; when transcoding, keyed sidecars stay external and selected
/// embedded text subtitles are embedded in the HTTP/MKV stream so subtitles
/// are never burned in.
/// URL. Keyed subtitle tracks remain external sidecars; selected internal
/// text tracks become segmented WebVTT and image tracks are burned into the
/// HLS rendition.
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
try {
@@ -3006,7 +2981,6 @@ class PlexClient
}
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final resumeOffsetMs = options.metadata.viewOffsetMs;
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
@@ -3017,7 +2991,6 @@ class PlexClient
transcodeSessionId: options.transcodeSessionId!,
audioStreamId: resolvedAudioId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: resumeOffsetMs != null && resumeOffsetMs > 0 ? resumeOffsetMs : null,
);
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
@@ -3111,7 +3084,9 @@ class PlexClient
/// Used by the in-player OpenSubtitles polling flow which needs the URL
/// after the new track shows up in the metadata response.
String? buildExternalSubtitleUrl(MediaSubtitleTrack track) {
if (!track.isExternal || track.key == null || track.key!.isEmpty) return null;
if (!track.isExternal || track.key == null || track.key!.isEmpty) {
return null;
}
final token = config.token;
if (token == null) return null;
final ext = CodecUtils.getSubtitleExtension(track.codec);
@@ -3133,10 +3108,10 @@ class PlexClient
return CodecUtils.isTextSubtitleCodec(track.codec);
}
bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) {
if (track == null) return false;
if (track.key != null && track.key!.isNotEmpty) return false;
return CodecUtils.isEmbeddableSubtitleCodec(track.codec);
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;
}
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
@@ -3152,9 +3127,8 @@ class PlexClient
);
}
/// Build subtitle sidecars for Plex transcode playback. Only real keyed
/// sidecars are loaded externally; selected embedded subtitles (text or
/// image) are carried by the main HTTP/MKV stream.
/// Build subtitle sidecars for Plex transcode playback. Keyed tracks remain
/// external; the selected internal track is delivered by the HLS rendition.
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const [];
if (config.token == null) {
@@ -3573,7 +3547,9 @@ class PlexClient
MediaKind.show => 2,
_ => null,
};
if (plexType == null || !ids.hasAny || title == null || title.isEmpty) return null;
if (plexType == null || !ids.hasAny || title == null || title.isEmpty) {
return null;
}
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(String? years) async {
final response = await _getWithFailover(
@@ -3608,7 +3584,9 @@ class PlexClient
if (year != null) {
filtered = await attempt('${year - 1},$year,${year + 1}');
final modern = filtered.modern;
if (modern != null) return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(modern));
if (modern != null) {
return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(modern));
}
}
final unfiltered = await attempt(null);
+6 -9
View File
@@ -1003,7 +1003,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
}
}
/// Build a live TV stream URL (decision + start path).
/// Build a live TV HLS stream URL (decision + start path).
///
/// [sessionPath] and [sessionIdentifier] come from [_tuneChannel].
/// [transcodeSessionId] should be reused across seeks within the same
@@ -1024,7 +1024,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
'path': sessionPath,
'mediaIndex': '0',
'partIndex': '0',
'protocol': 'http',
'protocol': 'hls',
'fastSeek': '1',
'directPlay': '0',
'directStream': directStream ? '1' : '0',
@@ -1041,16 +1041,13 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
'copyts': '0',
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Chunked': '1',
'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(),
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
// Pinned rather than config.platform: this decision request is only
// known to work with the 'Plex Desktop' profile + 'Flutter' platform
// pairing, and real OS names map to server-side preset profiles.
'X-Plex-Platform': 'Flutter',
'X-Plex-Client-Profile-Name': 'Plex Desktop',
'X-Plex-Platform': 'Generic',
'X-Plex-Client-Profile-Name': 'Generic',
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
@@ -1092,7 +1089,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '/video/:/transcode/universal/start?$startQuery';
return '/video/:/transcode/universal/start.m3u8?$startQuery';
} catch (e, st) {
appLogger.e('Failed to build live stream path', error: e, stackTrace: st);
return null;
+6 -7
View File
@@ -43,9 +43,9 @@ 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.
/// Image-based (bitmap) subtitle codecs. Plex burns these into the video
/// when the selected output transport cannot carry a bitmap subtitle
/// rendition.
static bool isImageSubtitleCodec(String? codec) {
if (codec == null) return false;
return switch (codec.toLowerCase()) {
@@ -61,10 +61,9 @@ class CodecUtils {
};
}
/// 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) {
/// Subtitle codecs Plex can deliver in a transcode. Text codecs can become
/// segmented HLS WebVTT; image codecs can be burned into the video.
static bool isTranscodableSubtitleCodec(String? codec) {
return isTextSubtitleCodec(codec) || isImageSubtitleCodec(codec);
}
-42
View File
@@ -1,11 +1,6 @@
import '../mpv/mpv.dart';
const restartBeforePreviousItemThreshold = Duration(seconds: 3);
const plexTranscodeSeekRangeStartTolerance = Duration(milliseconds: 500);
const plexTranscodeSeekRangeEndGuard = Duration(milliseconds: 500);
const plexTranscodeSeekNoopTolerance = Duration(seconds: 1);
enum PlexTranscodeSeekAction { nativeSeek, restartTranscode }
bool shouldRestartBeforePreviousItem(Duration position) {
return position > restartBeforePreviousItemThreshold;
@@ -17,40 +12,3 @@ Duration clampSeekPosition(Player player, Duration position) {
if (duration > Duration.zero && position > duration) return duration;
return position;
}
/// Plex MKV-over-HTTP transcodes are only native-seeked inside ranges the
/// player reports as locally seekable. Anything outside those ranges needs a
/// server-offset transcode restart.
PlexTranscodeSeekAction resolvePlexTranscodeSeekAction({
required Duration currentPosition,
required Duration target,
required List<BufferRange> bufferRanges,
bool allowBufferedNativeSeek = true,
Duration rangeStartTolerance = plexTranscodeSeekRangeStartTolerance,
Duration rangeEndGuard = plexTranscodeSeekRangeEndGuard,
Duration noopTolerance = plexTranscodeSeekNoopTolerance,
}) {
final validRanges = bufferRanges.where((range) => range.end >= range.start).toList();
if (allowBufferedNativeSeek &&
_isInAnyBufferedSeekRange(target, validRanges, startTolerance: rangeStartTolerance, endGuard: rangeEndGuard)) {
return PlexTranscodeSeekAction.nativeSeek;
}
if ((target - currentPosition).abs() <= noopTolerance) {
return PlexTranscodeSeekAction.nativeSeek;
}
return PlexTranscodeSeekAction.restartTranscode;
}
bool _isInAnyBufferedSeekRange(
Duration target,
List<BufferRange> ranges, {
required Duration startTolerance,
required Duration endGuard,
}) {
for (final range in ranges) {
if (target >= range.start - startTolerance && target <= range.end - endGuard) return true;
}
return false;
}
@@ -6,7 +6,9 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
void _onRateChanged(double newRate) {
if (!mounted) return;
if (_isLongPressing) return;
if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) return;
if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) {
return;
}
final prev = _lastReportedRate;
if (prev != null && (prev - newRate).abs() < 0.005) return;
_lastReportedRate = newRate;
@@ -80,13 +82,8 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
await (widget.onPlayPauseRequested ?? widget.player.playOrPause)();
}
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms.
void _throttledSeek(Duration position) {
if (widget.isTranscoding) {
_lastDispatchedTimelineSeek = null;
_lastDispatchedTimelineSeekFuture = null;
return;
}
_seekThrottle([position]);
}
@@ -104,11 +101,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
_lastDispatchedTimelineSeek = null;
_lastDispatchedTimelineSeekFuture = null;
if (shouldSkipDuplicateTimelineSeek(
isTranscoding: widget.isTranscoding,
lastDispatchedSeek: lastDispatched,
finalSeek: clamped,
)) {
if (shouldSkipDuplicateTimelineSeek(lastDispatchedSeek: lastDispatched, finalSeek: clamped)) {
if (seekFuture == null) {
widget.onSeekCompleted?.call(clamped);
return;
@@ -322,7 +315,9 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
onTimeout: () => null,
);
if (!mounted) return;
if (_pendingEdgeAdjustmentSide != side || _pendingEdgeAdjustmentGeneration != generation) return;
if (_pendingEdgeAdjustmentSide != side || _pendingEdgeAdjustmentGeneration != generation) {
return;
}
final latestDelta = _pendingEdgeAdjustmentDelta;
_clearPendingEdgeAdjustment();
@@ -485,9 +480,6 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
}
/// Handle stacking skip - add to accumulated skip when feedback is active.
/// Feedback refreshes before the seek is issued: the seek can be slow (a
/// transcode restart does a server round-trip) and the pill must react to
/// the tap, not to seek completion.
void _handleStackingSkip({required bool isForward}) {
if (!widget.canControl) return;
+21 -18
View File
@@ -96,11 +96,10 @@ 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.
/// While transcoding, only tracks Plex can deliver in HLS are shown: keyed
/// sidecars, text codecs that can become WebVTT, and image codecs that can be
/// burned into the rendition. 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,
@@ -109,7 +108,7 @@ List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
return tracks
.where((track) {
final hasKey = track.key != null && track.key!.isNotEmpty;
return hasKey || CodecUtils.isEmbeddableSubtitleCodec(track.codec);
return hasKey || CodecUtils.isTranscodableSubtitleCodec(track.codec);
})
.toList(growable: false);
}
@@ -316,7 +315,9 @@ bool primePlayerNavigationFocusForEvent(
required bool isAppleTV,
}) {
if (!isCurrentRoute || playerReady || event is! KeyDownEvent) return false;
if (classifyPlayerNavigationKey(event, isAppleTV: isAppleTV) == PlayerNavigationKey.none) return false;
if (classifyPlayerNavigationKey(event, isAppleTV: isAppleTV) == PlayerNavigationKey.none) {
return false;
}
focusNode.requestFocus();
return true;
}
@@ -346,12 +347,8 @@ KeyEventResult handlePlayerNavigationKeyAction(
}
@visibleForTesting
bool shouldSkipDuplicateTimelineSeek({
required bool isTranscoding,
required Duration? lastDispatchedSeek,
required Duration finalSeek,
}) {
return !isTranscoding && lastDispatchedSeek == finalSeek;
bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, required Duration finalSeek}) {
return lastDispatchedSeek == finalSeek;
}
typedef PlaybackSourceChangeCallback =
@@ -395,8 +392,8 @@ class PlexVideoControls extends StatefulWidget {
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// Called for app-level seek requests. Plex transcodes use this to restart
/// the server-side transcode session at the requested absolute timestamp.
/// Called for app-level seek requests so the owning screen can coordinate
/// playback state around the native player seek.
final Future<void> Function(Duration position)? onSeekRequested;
/// Called for app-level play/pause requests so the owning screen can track
@@ -957,7 +954,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
onEnd: () {
if (!_showControls) {
widget.chromeController.markControlsHidden();
if (_controlsMounted) setState(() => _controlsMounted = false);
if (_controlsMounted) {
setState(() => _controlsMounted = false);
}
}
},
child: Builder(
@@ -1108,7 +1107,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
valueListenable: _edgeAdjustmentIndicator,
builder: (context, indicator, _) {
final side = indicator.side;
if (side == null) return const SizedBox.shrink();
if (side == null) {
return const SizedBox.shrink();
}
return AnimatedOpacity(
opacity: indicator.visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 160),
@@ -1132,7 +1133,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
right: 24,
bottom: () {
if (!_showControls) return 24.0;
if (widget.chromeController.contentStripVisible) return 180.0;
if (widget.chromeController.contentStripVisible) {
return 180.0;
}
return isMobile ? 80.0 : 115.0;
}(),
child: AnimatedOpacity(
@@ -88,7 +88,9 @@ void main() {
final requests = <String>[];
final client = makeClient((request) async {
requests.add(request.url.path);
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
return jsonResponse(const {});
});
addTearDown(client.close);
@@ -106,10 +108,14 @@ void main() {
expect(requests, ['/livetv/dvrs/dvr-1/channels/ch-1/tune']);
});
test('streamUrlAt builds live-edge and offset URLs against one transcode session', () async {
test('streamUrlAt builds live-edge and offset HLS URLs against one transcode session', () async {
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path == '/video/:/transcode/universal/decision') return http.Response('ok', 200);
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') {
return http.Response('ok', 200);
}
return jsonResponse(const {});
});
addTearDown(client.close);
@@ -121,8 +127,12 @@ void main() {
expect(liveEdge, isNotNull);
final liveEdgeUri = Uri.parse(liveEdge!);
expect(liveEdgeUri.path, '/video/:/transcode/universal/start');
expect(liveEdgeUri.path, '/video/:/transcode/universal/start.m3u8');
expect(liveEdgeUri.queryParameters['path'], '/livetv/sessions/session-abc');
expect(liveEdgeUri.queryParameters['protocol'], 'hls');
expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse);
expect(liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('protocol=hls&container=mpegts'));
expect(liveEdgeUri.queryParameters['X-Plex-Token'], 'tok');
expect(liveEdgeUri.queryParameters.containsKey('offset'), isFalse);
@@ -136,7 +146,9 @@ void main() {
test('reportTimeline targets the tuned program and grows duration to the position', () async {
Map<String, String>? timelineQuery;
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/:/timeline') {
timelineQuery = request.url.queryParameters;
return jsonResponse({
@@ -169,7 +181,9 @@ void main() {
final requests = <Uri>[];
final client = makeClient((request) async {
requests.add(request.url);
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/:/timeline') {
throw http.ClientException('temporary timeline DNS failure', request.url);
}
@@ -194,7 +208,9 @@ void main() {
tunes++;
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') return http.Response('ok', 200);
if (request.url.path == '/video/:/transcode/universal/decision') {
return http.Response('ok', 200);
}
return jsonResponse(const {});
});
addTearDown(client.close);
@@ -365,7 +365,7 @@ void main() {
expect(subtitles, isEmpty);
});
test('selected internal text subtitles are embedded in HTTP MKV transcode', () {
test('selected internal text subtitles are segmented into the HLS transcode', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -384,33 +384,42 @@ void main() {
),
);
expect(params['protocol'], 'http');
expect(params['subtitles'], 'embedded');
expect(params['protocol'], 'hls');
expect(params['subtitles'], 'segmented');
expect(params['subtitleStreamID'], '401');
expect(params['advancedSubtitles'], 'text');
expect(params['X-Plex-Chunked'], '1');
expect(params.containsKey('X-Plex-Incomplete-Segments'), isFalse);
expect(params['X-Plex-Client-Profile-Extra'], contains('add-settings(DirectPlayStreamSelection=true)'));
expect(params.containsKey('X-Plex-Chunked'), isFalse);
expect(params['X-Plex-Incomplete-Segments'], '1');
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
final profile = params['X-Plex-Client-Profile-Extra'];
expect(profile, contains('add-settings(DirectPlayStreamSelection=true)'));
expect(
params['X-Plex-Client-Profile-Extra'],
profile,
contains(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=3000&replace=true)',
),
);
expect(
profile,
contains(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
),
);
expect(
params['X-Plex-Client-Profile-Extra'],
profile,
contains(
'add-transcode-target-settings(type=videoProfile&context=streaming'
'&protocol=http&CopyMatroskaAttachments=true)',
'add-transcode-target(type=subtitleProfile&context=streaming'
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)',
),
);
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls')));
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
expect(profile, isNot(contains('protocol=http&container=mkv')));
});
test('transcode start path uses HTTP start endpoint without token', () {
test('transcode start path uses the HLS manifest endpoint without token', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -420,16 +429,13 @@ void main() {
preset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
offsetMs: 90500,
);
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
expect(params['offset'], '90');
expect(startPath, startsWith('/video/:/transcode/universal/start?'));
expect(startPath, isNot(contains('start.m3u8')));
expect(startPath, contains('protocol=http'));
expect(startPath, contains('offset=90'));
expect(startPath, startsWith('/video/:/transcode/universal/start.m3u8?'));
expect(startPath, contains('protocol=hls'));
expect(startPath, isNot(contains('offset=')));
expect(startPath, isNot(contains('X-Plex-Token')));
});
@@ -450,7 +456,7 @@ void main() {
expect(params['partIndex'], '2');
});
test('selected image-based subtitles are embedded in HTTP MKV transcode without advancedSubtitles', () {
test('selected image subtitles are burned into HLS without advancedSubtitles', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -469,21 +475,18 @@ void main() {
),
);
// 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['subtitles'], 'burn');
expect(params['subtitleStreamID'], '401');
expect(params['protocol'], 'http');
expect(params['protocol'], 'hls');
expect(params.containsKey('advancedSubtitles'), isFalse);
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
});
test('image-based embedded subtitles are carried in the MKV, not as sidecars', () {
test('image-based embedded subtitles are rendered by HLS, not attached 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`.
// Embedded bitmap streams have no Plex `key`; the HLS request burns the
// selected track into the video rendition.
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),
+4 -4
View File
@@ -75,12 +75,12 @@ void main() {
}
});
test('isEmbeddableSubtitleCodec covers text and image, not unknown', () {
test('isTranscodableSubtitleCodec 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.isTranscodableSubtitleCodec(codec), isTrue, reason: codec);
}
expect(CodecUtils.isEmbeddableSubtitleCodec('weird'), isFalse);
expect(CodecUtils.isEmbeddableSubtitleCodec(null), isFalse);
expect(CodecUtils.isTranscodableSubtitleCodec('weird'), isFalse);
expect(CodecUtils.isTranscodableSubtitleCodec(null), isFalse);
});
});
+1 -120
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart' show BufferRange, Player, PlayerState;
import 'package:plezy/mpv/mpv.dart' show Player, PlayerState;
import 'package:plezy/utils/player_utils.dart';
void main() {
@@ -33,125 +33,6 @@ void main() {
expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 6));
});
});
group('resolvePlexTranscodeSeekAction', () {
test('uses native seek for backward targets inside a local buffer range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 20),
bufferRanges: const [BufferRange(start: Duration(seconds: 10), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts for backward targets outside local buffer ranges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 20),
bufferRanges: const [BufferRange(start: Duration(seconds: 25), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('uses native seek for tiny backward or no-op seeks inside the deadband', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(milliseconds: 29500),
bufferRanges: const [],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('uses native seek when the target is inside the local buffer range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 40),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts when buffered native seeks are disabled for the active backend', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 40),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
allowBufferedNativeSeek: false,
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts near the tail of a buffer range to avoid optimistic cache edges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(milliseconds: 49600),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts when a forward target is outside local buffer ranges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 58),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('uses native seek when the target is inside a later cached range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 5),
target: const Duration(seconds: 33),
bufferRanges: const [
BufferRange(start: Duration(seconds: 0), end: Duration(seconds: 10)),
BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 35)),
],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts for gaps far beyond the active range even when a later range exists', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 5),
target: const Duration(seconds: 25),
bufferRanges: const [
BufferRange(start: Duration(seconds: 0), end: Duration(seconds: 10)),
BufferRange(start: Duration(seconds: 40), end: Duration(seconds: 50)),
],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts large seeks when no buffer information exists', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 35),
bufferRanges: const [],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
});
}
class _FakePlayer implements Player {
+2 -19
View File
@@ -1192,10 +1192,9 @@ void main() {
});
group('shouldSkipDuplicateTimelineSeek', () {
test('skips a matching non-transcode final seek', () {
test('skips a matching final seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
@@ -1203,32 +1202,16 @@ void main() {
);
});
test('does not skip matching transcode seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: true,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
});
test('does not skip when no matching seek was already dispatched', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: null,
finalSeek: const Duration(minutes: 7, seconds: 30),
),
shouldSkipDuplicateTimelineSeek(lastDispatchedSeek: null, finalSeek: const Duration(minutes: 7, seconds: 30)),
isFalse,
);
});