From abf1027b7717aa44d6f8fd5ec44795db208594c2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:05:13 +0200 Subject: [PATCH] feat(plex): migrate video transcoding to HLS --- .../parts/episode_navigation.dart | 13 +- .../video_player/parts/media_controls.dart | 2 +- .../video_player/parts/playback_open.dart | 14 +- .../video_player/parts/playback_start.dart | 5 +- lib/screens/video_player/parts/seeking.dart | 157 +---------------- lib/screens/video_player_screen.dart | 20 +-- lib/services/plex_client.dart | 162 ++++++++---------- lib/services/plex_client/parts/live_tv.dart | 15 +- lib/utils/codec_utils.dart | 13 +- lib/utils/player_utils.dart | 42 ----- .../video_controls/parts/playback_input.dart | 24 +-- .../video_controls/video_controls.dart | 39 +++-- .../live_tv_playback_session_test.dart | 32 +++- .../plex_playback_data_request_test.dart | 63 +++---- test/utils/codec_utils_test.dart | 8 +- test/utils/player_utils_test.dart | 121 +------------ test/widgets/video_controls_test.dart | 21 +-- 17 files changed, 201 insertions(+), 550 deletions(-) diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 946c4f17..0ac7b089 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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, diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index bfc8e715..36dc30ce 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -33,7 +33,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { final playbackState = context.read(); 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; diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 5df2d957..3158ec95 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -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), diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 472bf720..01464c16 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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, diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 859444ed..4d6a8713 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -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 _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(); - final playbackResolver = PlaybackSourceResolver( - serverManager: context.read().serverManager, - database: context.read(), - ); - - 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; - } - } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a357eb6a..908aafbe 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -133,7 +133,7 @@ Future _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 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; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7c456f29..39986700 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 = ['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 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 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 = ['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 { '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 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 _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? modern, Map? 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); diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index eb23a4fb..1fd38a7a 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -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; diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index e61c38b7..b09b6dc7 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -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); } diff --git a/lib/utils/player_utils.dart b/lib/utils/player_utils.dart index 0053a2a6..000c49a0 100644 --- a/lib/utils/player_utils.dart +++ b/lib/utils/player_utils.dart @@ -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 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 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; -} diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index b3f08a69..bb6cc8cb 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -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; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f6677df1..56d39944 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 selectableSourceSubtitleTracks( List tracks, { required bool isTranscoding, @@ -109,7 +108,7 @@ List 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 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 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 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 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( diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 73dbcd0a..03cd3f4e 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -88,7 +88,9 @@ void main() { final requests = []; 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? 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 = []; 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); diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 4f313792..cd437680 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -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), diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart index 9b9a8eb1..b79673ce 100644 --- a/test/utils/codec_utils_test.dart +++ b/test/utils/codec_utils_test.dart @@ -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); }); }); diff --git a/test/utils/player_utils_test.dart b/test/utils/player_utils_test.dart index b0ac2c52..253754ea 100644 --- a/test/utils/player_utils_test.dart +++ b/test/utils/player_utils_test.dart @@ -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 { diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index ff31bbb3..1f2b171a 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -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, ); });