feat(plex): migrate video transcoding to HLS
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user