From c93a266722151245ca1dc81c5a23ae29b3d48e75 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 20 May 2026 17:21:31 +0200 Subject: [PATCH] fix(plex): support no-burn transcode subtitles --- lib/media/media_source_info.dart | 19 +- lib/mpv/player/platform/player_android.dart | 6 +- lib/mpv/player/player.dart | 9 +- lib/mpv/player/player_base.dart | 39 ++- lib/mpv/player/player_native.dart | 6 +- lib/screens/video_player/parts/build.dart | 28 +- .../video_player/parts/companion_remote.dart | 4 +- .../parts/episode_navigation.dart | 11 +- .../video_player/parts/media_controls.dart | 4 +- .../video_player/parts/playback_services.dart | 4 +- .../video_player/parts/playback_start.dart | 14 +- lib/screens/video_player/parts/seeking.dart | 138 ++++++++ lib/screens/video_player_screen.dart | 5 +- lib/services/keyboard_shortcuts_service.dart | 5 +- lib/services/plex_client.dart | 320 +++++++++++------- lib/services/track_manager.dart | 30 +- lib/utils/codec_utils.dart | 8 + .../desktop_video_controls.dart | 6 + .../video_controls/mobile_video_controls.dart | 4 + .../models/track_controls_state.dart | 8 + .../video_controls/parts/key_events.dart | 1 + .../video_controls/parts/navigation.dart | 20 +- .../video_controls/parts/playback_input.dart | 11 +- .../video_controls/parts/track_controls.dart | 13 + .../video_controls/sheets/chapter_sheet.dart | 4 +- .../video_controls/sheets/track_sheet.dart | 179 ++++++++-- .../video_controls/video_controls.dart | 28 ++ .../video_controls/widgets/content_strip.dart | 4 +- .../widgets/track_chapter_controls.dart | 10 +- test/media/media_source_info_test.dart | 37 ++ test/mpv/player_open_test.dart | 43 +++ .../plex_playback_data_request_test.dart | 253 ++++++++++++++ test/services/track_manager_test.dart | 14 + test/widgets/video_controls_test.dart | 10 + 34 files changed, 1103 insertions(+), 192 deletions(-) create mode 100644 lib/screens/video_player/parts/seeking.dart create mode 100644 test/media/media_source_info_test.dart create mode 100644 test/services/plex_playback_data_request_test.dart diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index dddc2859..09947ee7 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -151,15 +151,30 @@ class MediaSubtitleTrack with _TrackLabelMixin { }); String get label { + return labelForIndex(_fallbackLabelIndex); + } + + String labelForIndex(int visibleIndex) { return TrackLabelBuilder.buildSubtitleLabel( - title: displayTitle ?? title, + title: _labelTitle, language: languageCode ?? language, codec: codec, forced: forced, - index: (index ?? id) - 1, + index: visibleIndex, ); } + String? get _labelTitle { + final explicitTitle = title; + if (explicitTitle != null && explicitTitle.trim().isNotEmpty) return explicitTitle; + return displayTitle; + } + + int get _fallbackLabelIndex { + final streamIndex = index ?? id; + return streamIndex > 0 ? streamIndex - 1 : 0; + } + /// Returns true if this subtitle track is an external file (sidecar subtitle). /// Some backends provide a direct key/URL, others require constructing one /// from stream metadata. diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 028c68a9..80120504 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -97,10 +97,13 @@ class PlayerAndroid extends PlayerBase { bool play = true, bool isLive = false, List? externalSubtitles, + Duration timelineOffset = Duration.zero, + Duration? timelineDuration, }) async { if (disposed) return; await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; + configureTimeline(offset: timelineOffset, duration: timelineDuration); clearTracks(); resetPlaybackProgress(startPosition); setSeekable(false); @@ -141,7 +144,8 @@ class PlayerAndroid extends PlayerBase { @override Future seek(Duration position) async { - await runSeek(position, () => invoke('seek', {'positionMs': position.inMilliseconds})); + final sourcePosition = sourceSeekPosition(position); + await runSeek(position, () => invoke('seek', {'positionMs': sourcePosition.inMilliseconds})); } @override diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index d5b82ab0..1a19fcc2 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -58,7 +58,14 @@ abstract class Player { /// /// [media] - The media source to open. /// [play] - Whether to start playback immediately (default: true). - Future open(Media media, {bool play = true, bool isLive = false, List? externalSubtitles}); + Future open( + Media media, { + bool play = true, + bool isLive = false, + List? externalSubtitles, + Duration timelineOffset = Duration.zero, + Duration? timelineDuration, + }); /// Start or resume playback. Future play(); diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 901c07d4..e98083d3 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -46,6 +46,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { int _lastEmitMs = 0; int _lastCacheStateMs = 0; int _positionMs = 0; + Duration _timelineOffset = Duration.zero; + Duration? _timelineDuration; int _nextPropId = 0; final Map _propIdToName = {}; @@ -142,13 +144,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'time-pos': if (value is num) { - _positionMs = (value * 1000).round(); + final pos = _toTimelinePosition(Duration(milliseconds: (value * 1000).round())); + _positionMs = pos.inMilliseconds; // Only allocate Duration + copyWith + emit at ~4Hz (250ms). // Raw int is stored every tick so synchronous reads via _positionMs stay current. final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastEmitMs >= 250) { _lastEmitMs = nowMs; - final pos = Duration(milliseconds: _positionMs); _state = _state.copyWith(position: pos); positionController.add(pos); } @@ -157,7 +159,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'duration': if (value is num) { - final duration = Duration(milliseconds: (value * 1000).toInt()); + final duration = _timelineDuration ?? _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); _state = _state.copyWith(duration: duration); durationController.add(duration); } @@ -174,7 +176,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) break; _lastCacheStateMs = nowMs; - final buffer = Duration(milliseconds: (value * 1000).toInt()); + final buffer = _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). @@ -295,7 +297,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) final cacheEnd = cacheState['cache-end'] as num?; if (cacheEnd != null) { - final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt()); + final buffer = _toTimelinePosition(Duration(milliseconds: (cacheEnd * 1000).toInt())); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); } @@ -311,8 +313,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (start != null && end != null) { ranges.add( BufferRange( - start: Duration(milliseconds: (start * 1000).toInt()), - end: Duration(milliseconds: (end * 1000).toInt()), + start: _toTimelinePosition(Duration(milliseconds: (start * 1000).toInt())), + end: _toTimelinePosition(Duration(milliseconds: (end * 1000).toInt())), ), ); } @@ -491,18 +493,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } @protected - void resetPlaybackProgress(Duration position) { + void configureTimeline({Duration offset = Duration.zero, Duration? duration}) { + _timelineOffset = offset; + _timelineDuration = duration; + } + + @protected + Duration sourceSeekPosition(Duration timelinePosition) { + final sourcePosition = timelinePosition - _timelineOffset; + return sourcePosition.isNegative ? Duration.zero : sourcePosition; + } + + Duration _toTimelinePosition(Duration sourcePosition) { + return sourcePosition + _timelineOffset; + } + + @protected + void resetPlaybackProgress(Duration sourcePosition) { + final position = _toTimelinePosition(sourcePosition); _positionMs = position.inMilliseconds; _state = _state.copyWith( completed: false, position: position, - duration: Duration.zero, + duration: _timelineDuration ?? Duration.zero, buffer: Duration.zero, bufferRanges: const [], ); completedController.add(false); positionController.add(position); - durationController.add(Duration.zero); + durationController.add(_timelineDuration ?? Duration.zero); bufferController.add(Duration.zero); bufferRangesController.add(const []); } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 6ce52099..0e996d26 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -102,10 +102,13 @@ class PlayerNative extends PlayerBase { bool play = true, bool isLive = false, List? externalSubtitles, + Duration timelineOffset = Duration.zero, + Duration? timelineDuration, }) async { if (disposed) return; await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; + configureTimeline(offset: timelineOffset, duration: timelineDuration); clearTracks(); resetPlaybackProgress(startPosition); setSeekable(false); @@ -160,7 +163,8 @@ class PlayerNative extends PlayerBase { @override Future seek(Duration position) async { - await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute'])); + final sourcePosition = sourceSeekPosition(position); + await runSeek(position, () => command(['seek', (sourcePosition.inMilliseconds / 1000.0).toString(), 'absolute'])); } @override diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 1d93069a..b44ca5c9 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -46,6 +46,25 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { }); } + int? _selectedSourceSubtitleStreamId(List tracks) { + if (tracks.isEmpty) return null; + for (final track in tracks) { + if (track.selected) return track.id; + } + return 0; + } + + List _sourceSubtitleTracksForControls() { + final tracks = _currentMediaInfo?.subtitleTracks ?? const []; + if (!_isTranscoding) return tracks; + return tracks + .where((track) { + final hasKey = track.key != null && track.key!.isNotEmpty; + return hasKey || CodecUtils.isTextSubtitleCodec(track.codec); + }) + .toList(growable: false); + } + Widget _buildLoadingSpinner() { return const Scaffold( backgroundColor: Colors.black, @@ -201,6 +220,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; } + final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; + final sourceSubtitleTracks = _sourceSubtitleTracksForControls(); + return Video( player: player!, controls: (context) => plexVideoControlsBuilder( @@ -215,8 +237,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { serverSupportsTranscoding: _serverSupportsTranscoding, isTranscoding: _isTranscoding, isOfflinePlayback: _isOfflinePlayback, - sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [], + sourceAudioTracks: sourceAudioTracks, selectedAudioStreamId: _selectedAudioStreamId, + sourceSubtitleTracks: sourceSubtitleTracks, + selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks), + sourcePartId: _currentMediaInfo?.partId, onTogglePIPMode: _togglePIPMode, boxFitMode: _videoFilterManager?.boxFitMode ?? 0, onCycleBoxFitMode: _cycleBoxFitMode, @@ -225,6 +250,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onAudioTrackChanged: _onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, + onSeekRequested: _seekPlayback, onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, onReachedEnd: ({skipAutoPlayCountdown = false}) => diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index 19e2679b..7b614823 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -21,7 +21,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { return; } final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds)); - await player!.seek(target); + await _seekPlayback(target); }; receiver.onSeekBackward = () async { if (player == null) return; @@ -32,7 +32,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { return; } final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds)); - await player!.seek(target); + await _seekPlayback(target); }; receiver.onVolumeUp = () async { if (player == null) return; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 6ec5df62..33409ab1 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -48,7 +48,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { }); final target = clampSeekPosition(currentPlayer, Duration.zero); - await currentPlayer.seek(target); + await _seekPlayback(target); if (!mounted || currentPlayer != player) return; _notifyWatchTogetherSeek(target); @@ -216,10 +216,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { await currentPlayer.setDisplayCriteria( !result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, ); + final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero; + final transcodeTimelineDuration = result.isTranscoding && episodeMetadata.durationMs != null + ? Duration(milliseconds: episodeMetadata.durationMs!) + : null; + await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); await currentPlayer.open( - Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), + Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders), play: isExoPlayer || !hasExternalSubs, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + timelineOffset: transcodeTimelineOffset, + timelineDuration: transcodeTimelineDuration, ); _completionTriggered = false; diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index 0c9340d7..22775226 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -79,7 +79,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { final playbackState = context.read(); final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; - final canSeek = !widget.isLive && currentPlayer.state.seekable; + final canSeek = !widget.isLive && (currentPlayer.state.seekable || _shouldRestartPlexTranscodeForSeek); if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; @@ -93,7 +93,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { Future _seekBackForRewind(Player p) async { if (_rewindOnResume <= 0) return; final target = p.state.position - Duration(seconds: _rewindOnResume); - await p.seek(clampSeekPosition(p, target)); + await _seekPlayback(clampSeekPosition(p, target)); } Future _restoreMediaControlsAfterResume() async { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 20f38179..eecf3f8d 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -131,7 +131,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _updateMediaControlsPlaybackState(); } else if (event is SeekEvent) { appLogger.d('Media control: Seek event received to ${event.position}'); - unawaited(activePlayer!.seek(clampSeekPosition(activePlayer, event.position))); + unawaited(_seekPlayback(clampSeekPosition(activePlayer!, event.position))); } else if (event is NextTrackEvent) { appLogger.d('Media control: Next track event received'); if (_nextEpisode != null) _playNext(); @@ -246,6 +246,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (_completionTriggered && !_showPlayNextDialog && _autoPlayTimer?.isActive != true) { _completionTriggered = false; } - p.seek(pos); + unawaited(_seekPlayback(pos)); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index e46adf02..e7d2cae3 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -95,6 +95,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } _livePlaybackStartTime = DateTime.now(); + await currentPlayer.setProperty('force-seekable', 'no'); await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); if (!mounted || player != currentPlayer) return; @@ -305,10 +306,21 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // ExoPlayer: attach external subs at open time so it discovers // them in a single prepare() — no media reload needed for selection. // MPV (all platforms including Android): external subs added after open via sub-add. + final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero; + final transcodeTimelineDuration = result.isTranscoding && _currentMetadata.durationMs != null + ? Duration(milliseconds: _currentMetadata.durationMs!) + : null; + // Plex's chunked HTTP/MKV transcode can be seekable even when MPV + // cannot prove it from response headers. Force only for that path and + // reset everywhere else so live/direct/offline streams keep native + // seekability detection. + await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); await currentPlayer.open( - Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), + Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders), play: shouldAutoPlay, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + timelineOffset: transcodeTimelineOffset, + timelineDuration: transcodeTimelineDuration, ); if (!mounted || player != currentPlayer) return; diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart new file mode 100644 index 00000000..00a768b1 --- /dev/null +++ b/lib/screens/video_player/parts/seeking.dart @@ -0,0 +1,138 @@ +part of '../../video_player_screen.dart'; + +extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { + Future _seekPlayback(Duration position) async { + final currentPlayer = player; + if (!mounted || currentPlayer == null) return; + + final target = clampSeekPosition(currentPlayer, position); + if (!_shouldRestartPlexTranscodeForSeek) { + await currentPlayer.seek(target); + return; + } + + if (_canSeekWithinCurrentTranscodeBuffer(currentPlayer, target)) { + await currentPlayer.seek(target); + return; + } + + await _restartPlexTranscodeAt(target); + } + + bool get _shouldRestartPlexTranscodeForSeek { + return _isTranscoding && + !widget.isLive && + !_isOfflinePlayback && + _currentMetadata.backend == MediaBackend.plex && + _selectedQualityPreset != TranscodeQualityPreset.original; + } + + bool _canSeekWithinCurrentTranscodeBuffer(Player currentPlayer, Duration target) { + const edgeTolerance = Duration(milliseconds: 500); + final targetMs = target.inMilliseconds; + for (final range in currentPlayer.state.bufferRanges) { + final startMs = range.start.inMilliseconds - edgeTolerance.inMilliseconds; + final endMs = range.end.inMilliseconds - edgeTolerance.inMilliseconds; + if (targetMs >= startMs && targetMs <= endMs) return true; + } + return false; + } + + Future _restartPlexTranscodeAt(Duration target) async { + if (_isRestartingTranscodeSeek) return; + + appLogger.d('Restarting Plex transcode at ${target.inSeconds}s'); + _isRestartingTranscodeSeek = true; + _controlsVisible.value = true; + + final currentPlayer = player; + if (currentPlayer == null) { + _isRestartingTranscodeSeek = false; + return; + } + + final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds); + final wasPlaying = currentPlayer.state.playing; + final nextTranscodeSessionId = generateSessionIdentifier(); + + try { + final mediaClient = _getMediaServerClient(context); + if (mediaClient == null) { + throw StateError('No client registered for ${replacementMetadata.serverId}'); + } + + _playbackTranscodeSessionId = nextTranscodeSessionId; + final playbackService = PlaybackInitializationService(client: mediaClient, database: context.read()); + final result = await playbackService.getPlaybackData( + metadata: replacementMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, + preferOffline: false, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ); + if (!mounted || player != currentPlayer) return; + if (result.videoUrl == null) { + throw PlaybackException(t.messages.fileInfoNotAvailable); + } + + _currentMetadata = replacementMetadata; + _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; + _selectedAudioStreamId = result.activeAudioStreamId; + _availableVersions = result.availableVersions; + _currentMediaInfo = result.mediaInfo; + + final isExoPlayer = currentPlayer is PlayerAndroid; + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + final shouldAutoPlay = wasPlaying && (isExoPlayer || !hasExternalSubs); + final timelineDuration = _currentMetadata.durationMs != null + ? Duration(milliseconds: _currentMetadata.durationMs!) + : null; + + await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no'); + await currentPlayer.open( + Media(result.videoUrl!, start: result.isTranscoding ? null : target, headers: _streamHeaders), + play: shouldAutoPlay, + externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, + timelineOffset: result.isTranscoding ? target : Duration.zero, + timelineDuration: result.isTranscoding ? timelineDuration : null, + ); + if (!mounted || player != currentPlayer) return; + + _setPlayerState(() {}); + + final trackManager = _trackManager; + if (trackManager != null) { + trackManager.metadata = _currentMetadata; + trackManager.mediaInfo = _currentMediaInfo; + trackManager.cacheExternalSubtitles(result.externalSubtitles); + if (currentPlayer is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { + trackManager.waitingForExternalSubsTrackSelection = true; + await trackManager.addExternalSubtitles(result.externalSubtitles); + if (wasPlaying && mounted && player == currentPlayer) { + await trackManager.resumeAfterSubtitleLoad(); + } else { + trackManager.waitingForExternalSubsTrackSelection = false; + trackManager.applyTrackSelectionWhenReady(); + } + } else { + trackManager.applyTrackSelectionWhenReady(); + } + } + + _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 { + _isRestartingTranscodeSeek = false; + } + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6273db75..c593da49 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -59,6 +59,7 @@ import '../services/shader_service.dart'; import '../providers/shader_provider.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/codec_utils.dart'; import '../utils/dialogs.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/live_tv_player_navigation.dart'; @@ -92,6 +93,7 @@ part 'video_player/parts/shader.dart'; part 'video_player/parts/playback_prompts.dart'; part 'video_player/parts/playback_services.dart'; part 'video_player/parts/playback_start.dart'; +part 'video_player/parts/seeking.dart'; part 'video_player/parts/build.dart'; part 'video_player/parts/watch_together.dart'; @@ -262,7 +264,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // during otherwise-idle setup time. Future? _audioFocusFuture; late final String _playbackSessionIdentifier; - late final String _playbackTranscodeSessionId; + late String _playbackTranscodeSessionId; String? _playbackPlaySessionId; String? _playbackPlayMethod; StreamSubscription? _errorSubscription; @@ -273,6 +275,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _positionSubscription; StreamSubscription? _playbackRestartSubscription; StreamSubscription? _backendSwitchedSubscription; + bool _isRestartingTranscodeSeek = false; TrackManager? _trackManager; StreamSubscription? _logSubscription; StreamSubscription? _sleepTimerSubscription; diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index b07623eb..7af22655 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -185,6 +185,7 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onScreenshot, int? currentPositionEpoch, ValueChanged? onLiveSeek, + Future Function(Duration position)? onSeekRequested, }) { if (event is! KeyDownEvent) return KeyEventResult.ignored; @@ -262,6 +263,7 @@ class KeyboardShortcutsService extends ChangeNotifier { onScreenshot: onScreenshot, currentPositionEpoch: currentPositionEpoch, onLiveSeek: onLiveSeek, + onSeekRequested: onSeekRequested, ); return KeyEventResult.handled; } @@ -286,13 +288,14 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onScreenshot, int? currentPositionEpoch, ValueChanged? onLiveSeek, + Future Function(Duration position)? onSeekRequested, }) { void performSeek(int offsetSeconds) { if (onLiveSeek != null && currentPositionEpoch != null) { onLiveSeek(currentPositionEpoch + offsetSeconds); } else { final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); - unawaited(player.seek(target)); + unawaited((onSeekRequested ?? player.seek)(target)); } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 89e86318..8a6cd9db 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1485,7 +1485,7 @@ class PlexClient // deleted-but-still-indexed versions before play. networkCall: () => _http.get( '/library/metadata/$ratingKey', - queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1}, + queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1, 'includeStreams': 1}, ), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, @@ -2830,9 +2830,9 @@ class PlexClient /// Build a VOD transcode stream URL (decision + start path). /// /// Mirrors [buildLiveStreamPath] but for on-demand video with a quality - /// preset, selected audio stream, and HLS protocol. Plex returns a single - /// audio track and no subtitle tracks in the transcoded stream — callers - /// are expected to sidecar additional subtitles separately. + /// 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. /// /// [transcodeSessionId] and [sessionIdentifier] should be reused across /// seeks + quality/version/audio switches within one playback so the @@ -2844,96 +2844,20 @@ class PlexClient required String sessionIdentifier, required String transcodeSessionId, int? audioStreamId, + MediaSubtitleTrack? selectedSubtitleTrack, int? offsetMs, }) async { try { - final isOriginal = preset.isOriginal; - final metadataPath = '/library/metadata/$ratingKey'; - - // 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 = []; - if (!isOriginal && preset.videoBitrateKbps != null) { - profileExtraClauses.add( - 'add-limitation(scope=videoCodec&scopeName=*&type=upperBound' - '&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)', - ); - } - // Declare both h264 and hevc as allowed transcode targets. In practice - // Plex's decision engine strongly prefers h264 for HLS output, so hevc - // only gets chosen in edge cases (e.g. HDR content where the server - // wants to preserve dynamic range). The codec-list comma is pre-encoded - // as `%2C` — see the profile-extra encoding note above. - profileExtraClauses.add( - 'add-transcode-target(type=videoProfile&context=streaming' - '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)', + final allParams = _buildTranscodeParams( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + preset: preset, + sessionIdentifier: sessionIdentifier, + transcodeSessionId: transcodeSessionId, + audioStreamId: audioStreamId, + selectedSubtitleTrack: selectedSubtitleTrack, + offsetMs: offsetMs, ); - final clientProfileExtra = profileExtraClauses.join('+'); - - // HLS protocol: seekable via manifest segments. We started with `dash` - // (what Plex Web on Chrome uses) but Plex's server only has DASH - // transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop - // platforms fall through with "No conversion profile found for - // protocol dash". HLS profiles exist for every Plex-accepted platform. - final allParams = { - 'hasMDE': '1', - 'path': metadataPath, - 'mediaIndex': mediaIndex.toString(), - 'partIndex': '0', - 'protocol': 'hls', - 'fastSeek': '1', - 'directPlay': isOriginal ? '1' : '0', - 'directStream': isOriginal ? '1' : '0', - 'subtitleSize': '100', - 'audioBoost': '100', - 'location': 'lan', - if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(), - 'addDebugOverlay': '0', - 'autoAdjustQuality': '0', - 'directStreamAudio': '0', - 'mediaBufferSize': '102400', - 'session': transcodeSessionId, - // Subtitles are delivered as client-side sidecars (see - // [PlaybackInitializationService._buildTranscodeSidecarSubtitles]). - // `subtitles=none` makes the server set the subtitle decision to - // `ignore`, so nothing is embedded or burned into the video stream. - 'subtitles': 'none', - // Preserve source timestamps in the transcoded segments. Without it, - // Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar - // subtitles desync even though the server is transcoding from the - // `offset` position. With copyts=1 the first segment's PTS equals - // the source offset and the player's clock lines up with source time. - 'copyts': '1', - if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(), - 'Accept-Language': 'en', - 'X-Plex-Session-Identifier': sessionIdentifier, - 'X-Plex-Client-Profile-Extra': clientProfileExtra, - '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(), - if (config.device != null) 'X-Plex-Device': config.device!, - if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(), - if (config.token != null) 'X-Plex-Token': config.token!, - }; final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); @@ -2957,16 +2881,12 @@ class PlexClient return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } - final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal); + final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: preset.isOriginal); if (outcome == TranscodeDecisionOutcome.failed) { return (startPath: null, outcome: outcome); } - final startParams = Map.from(allParams)..remove('X-Plex-Token'); - final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); - - // `.m3u8` tells the server to return an HLS manifest. - return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome); + return (startPath: _buildTranscodeStartPathFromParams(allParams), outcome: outcome); } finally { decisionClient.close(); } @@ -2976,6 +2896,135 @@ class PlexClient } } + String _buildTranscodeStartPathFromParams(Map params) { + final startParams = Map.from(params)..remove('X-Plex-Token'); + final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); + return '/video/:/transcode/universal/start?$startQuery'; + } + + @visibleForTesting + String buildTranscodeStartPathFromParamsForTesting(Map params) { + return _buildTranscodeStartPathFromParams(params); + } + + Map _buildTranscodeParams({ + required String ratingKey, + required int mediaIndex, + required TranscodeQualityPreset preset, + required String sessionIdentifier, + required String transcodeSessionId, + int? audioStreamId, + MediaSubtitleTrack? selectedSubtitleTrack, + int? offsetMs, + }) { + final isOriginal = preset.isOriginal; + final selectedEmbeddedTextSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack) + ? selectedSubtitleTrack + : null; + + // 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 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': '0', + 'protocol': 'http', + 'fastSeek': '1', + 'directPlay': isOriginal ? '1' : '0', + 'directStream': isOriginal ? '1' : '0', + 'subtitleSize': '100', + 'audioBoost': '100', + 'location': 'lan', + if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(), + 'addDebugOverlay': '0', + 'autoAdjustQuality': '0', + 'directStreamAudio': '0', + 'mediaBufferSize': '102400', + 'session': transcodeSessionId, + // Embed selected text subtitles in the MKV stream. Bitmap subtitles and + // unselected tracks stay at `none` so the server cannot burn them into + // the video. + 'subtitles': selectedEmbeddedTextSubtitle != null ? 'embedded' : 'none', + if (selectedEmbeddedTextSubtitle != null) 'subtitleStreamID': selectedEmbeddedTextSubtitle.id.toString(), + if (selectedEmbeddedTextSubtitle != null) 'advancedSubtitles': 'text', + // Preserve source timestamps for the HTTP/MKV stream so player seeks and + // sidecar subtitles stay aligned with Plex source time. + 'copyts': '1', + 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-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(), + if (config.device != null) 'X-Plex-Device': config.device!, + if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(), + if (config.token != null) 'X-Plex-Token': config.token!, + }; + } + + @visibleForTesting + Map buildTranscodeParamsForTesting({ + required String ratingKey, + required int mediaIndex, + required TranscodeQualityPreset preset, + required String sessionIdentifier, + required String transcodeSessionId, + int? audioStreamId, + MediaSubtitleTrack? selectedSubtitleTrack, + int? offsetMs, + }) { + return _buildTranscodeParams( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + preset: preset, + sessionIdentifier: sessionIdentifier, + transcodeSessionId: transcodeSessionId, + audioStreamId: audioStreamId, + selectedSubtitleTrack: selectedSubtitleTrack, + offsetMs: offsetMs, + ); + } + /// Platform name Plex Media Server accepts on the transcode decision /// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400, /// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected. @@ -3148,8 +3197,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, every source subtitle is sidecar-attached so - /// the player can hot-swap. + /// 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. @override Future getPlaybackInitialization(PlaybackInitializationOptions options) async { try { @@ -3162,11 +3212,8 @@ class PlexClient final wantTranscode = !options.qualityPreset.isOriginal; if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) { final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo); - // Note: no `offsetMs` — seeking is handled by the player via the HLS - // manifest, matching Plex Web's behavior. Baking `offset=` into the URL - // makes the server pre-position the transcoder, but the resulting - // segments and mpv's native HLS positioning fight each other, leaving - // the player clock at 0 and desyncing sidecar subtitles. + final resumeOffsetMs = options.metadata.viewOffsetMs; + final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo); final result = await buildTranscodeStartPath( ratingKey: options.metadata.id, mediaIndex: options.selectedMediaIndex, @@ -3174,6 +3221,8 @@ class PlexClient sessionIdentifier: options.sessionIdentifier!, transcodeSessionId: options.transcodeSessionId!, audioStreamId: resolvedAudioId, + selectedSubtitleTrack: selectedSubtitleTrack, + offsetMs: resumeOffsetMs != null && resumeOffsetMs > 0 ? resumeOffsetMs : null, ); if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) { @@ -3236,6 +3285,14 @@ class PlexClient return tracks.first.id; } + MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) { + if (info == null) return null; + for (final track in info.subtitleTracks) { + if (track.selected) return track; + } + return null; + } + /// Build the absolute URL for an external subtitle track on this Plex /// server. Returns `null` for tracks that aren't external (no `/library/ /// streams/{id}` key) or when the server has no auth token. @@ -3243,28 +3300,50 @@ 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) 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); return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token'; } - /// Sidecar URL for any subtitle track (internal or external), used in - /// transcode mode where embedded subtitle streams are stripped. Falls - /// back to `/library/streams/{id}.{ext}` when [track.key] is missing. - /// Returns `null` when no auth token is available. + /// Raw sidecar URL for real sidecar subtitle streams. Plex returns 501 for + /// `/library/streams/{id}.{ext}` when the stream is embedded, so a Plex + /// `Stream.key` is required here. String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) { + if (track.key == null || track.key!.isEmpty) return null; final token = config.token; if (token == null) return null; final ext = CodecUtils.getSubtitleExtension(track.codec); - final path = (track.key != null && track.key!.isNotEmpty) ? track.key! : '/library/streams/${track.id}'; - return '${config.baseUrl}$path.$ext?encoding=utf-8&X-Plex-Token=$token'; + return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token'; } - /// Build sidecar SubtitleTracks for ALL source subtitle streams (internal + - /// external) so the player can hot-swap between them when the main stream - /// is transcoded and has no embedded subs. + bool _canTranscodeSubtitleAsText(MediaSubtitleTrack track) { + return CodecUtils.isTextSubtitleCodec(track.codec); + } + + bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) { + if (track == null) return false; + if (track.key != null && track.key!.isNotEmpty) return false; + return _canTranscodeSubtitleAsText(track); + } + + SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) { + return SubtitleTrack( + id: 'external:$url', + title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}', + language: track.languageCode, + codec: track.codec, + isDefault: track.selected, + isForced: track.forced, + isExternal: true, + uri: url, + ); + } + + /// Build subtitle sidecars for Plex transcode playback. Only real keyed + /// sidecars are loaded externally; selected embedded text subtitles are + /// carried by the main HTTP/MKV stream. List _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { if (mediaInfo == null) return const []; if (config.token == null) { @@ -3277,13 +3356,7 @@ class PlexClient try { final url = _buildSidecarSubtitleUrl(sub); if (url == null) continue; - tracks.add( - SubtitleTrack.uri( - url, - title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}', - language: sub.languageCode, - ), - ); + tracks.add(_subtitleTrackFromMediaTrack(sub, url)); } catch (e) { appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e); } @@ -3291,6 +3364,11 @@ class PlexClient return tracks; } + @visibleForTesting + List buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) { + return _buildTranscodeSidecarSubtitles(mediaInfo); + } + /// Build list of external subtitle tracks from media info List _buildExternalSubtitles(MediaSourceInfo? mediaInfo) { final externalSubtitles = []; @@ -3316,7 +3394,7 @@ class PlexClient externalSubtitles.add( SubtitleTrack.uri( url, - title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}', + title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}', language: plexTrack.languageCode, ), ); diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 968014e1..c84d64c8 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -66,6 +66,7 @@ class TrackManager { List _lastExternalSubtitles = const []; StreamSubscription? _trackLoadingSubscription; Timer? _subtitleFallbackTimer; + Timer? _trackSelectionFallbackTimer; /// Cached external subtitles for re-use after backend fallback. List get lastExternalSubtitles => _lastExternalSubtitles; @@ -109,7 +110,7 @@ class TrackManager { uri: subtitleTrack.uri!, title: subtitleTrack.title, language: subtitleTrack.language, - select: false, + select: subtitleTrack.isDefault, ); appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}'); } catch (e) { @@ -156,13 +157,23 @@ class TrackManager { /// If tracks are not yet loaded, subscribes to the stream. void applyTrackSelectionWhenReady() { final currentTracks = player.state.tracks; - if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) { + if (_tracksReadyForSelection(currentTracks)) { applyTrackSelection(); } else { _trackLoadingSubscription?.cancel(); _trackLoadingSubscription = player.streams.tracks.listen((tracks) { - if (tracks.audio.isEmpty && tracks.subtitle.isEmpty) return; + if (!_tracksReadyForSelection(tracks)) return; + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = null; + applyTrackSelection(); + }); + + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = Timer(const Duration(seconds: 5), () { + if (!isActive()) return; _trackLoadingSubscription?.cancel(); _trackLoadingSubscription = null; applyTrackSelection(); @@ -170,6 +181,17 @@ class TrackManager { } } + bool _tracksReadyForSelection(Tracks tracks) { + final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty; + if (!hasAnyTracks) return false; + + final info = mediaInfo; + if (info == null || tracks.subtitle.isNotEmpty) return true; + + final expectsSelectedSubtitle = info.subtitleTracks.any((track) => track.selected); + return !expectsSelectedSubtitle; + } + /// Core track selection: delegates to [TrackSelectionService]. Future applyTrackSelection() async { if (!isActive() || _isApplyingTrackSelection) return; @@ -460,5 +482,7 @@ class TrackManager { _trackLoadingSubscription = null; _subtitleFallbackTimer?.cancel(); _subtitleFallbackTimer = null; + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = null; } } diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index 5738f3b8..9027dc33 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -31,6 +31,14 @@ class CodecUtils { } } + static bool isTextSubtitleCodec(String? codec) { + if (codec == null) return false; + return switch (codec.toLowerCase()) { + 'srt' || 'subrip' || 'ass' || 'ssa' || 'webvtt' || 'vtt' || 'mov_text' => true, + _ => false, + }; + } + /// Formats a subtitle codec name to a user-friendly display format. /// /// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'. diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 63ee3516..edd92b10 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -97,6 +97,9 @@ class DesktopVideoControls extends StatefulWidget { /// Called when content strip visibility changes final ValueChanged? onContentStripVisibilityChanged; + /// Called when a seek should be executed by the owning screen. + final Future Function(Duration position)? onSeekRequested; + /// Called when a seek operation completes successfully. final Function(Duration position)? onSeekCompleted; @@ -139,6 +142,7 @@ class DesktopVideoControls extends StatefulWidget { this.onCancelAutoHide, this.onStartAutoHide, this.onContentStripVisibilityChanged, + this.onSeekRequested, this.onSeekCompleted, }); @@ -612,6 +616,7 @@ class DesktopVideoControlsState extends State { serverId: widget.serverId, showQueueTab: widget.showQueueTab, onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, onSeekCompleted: widget.onSeekCompleted, useFocusNavigation: true, onNavigateUp: _onContentStripNavigateUp, @@ -917,6 +922,7 @@ class DesktopVideoControlsState extends State { chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, trackControlsState: _trackControlsState, + onSeekRequested: widget.onSeekRequested, onSeekCompleted: widget.onSeekCompleted, focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 5c464c9b..a0759411 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -36,6 +36,7 @@ class MobileVideoControls extends StatefulWidget { final Widget trackChapterControls; final Function(Duration) onSeek; final Function(Duration) onSeekEnd; + final Future Function(Duration position)? onSeekRequested; final Function(Duration)? onSeekCompleted; final VoidCallback onPlayPause; final VoidCallback? onCancelAutoHide; @@ -92,6 +93,7 @@ class MobileVideoControls extends StatefulWidget { required this.onSeek, required this.onSeekEnd, required this.onPlayPause, + this.onSeekRequested, this.onSeekCompleted, this.onCancelAutoHide, this.onStartAutoHide, @@ -281,6 +283,8 @@ class _MobileVideoControlsState extends State with SingleTi serverId: widget.serverId, showQueueTab: widget.showQueueTab, onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, ), ], ), diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index d12bf25c..1a8ca658 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -16,6 +16,9 @@ class TrackControlsState { final bool isTranscoding; final List sourceAudioTracks; final int? selectedAudioStreamId; + final List sourceSubtitleTracks; + final int? selectedSubtitleStreamId; + final int? sourcePartId; /// Total media duration in milliseconds. Used by the version/quality sheet /// to show estimated file sizes per preset (bitrate × duration). @@ -36,6 +39,7 @@ class TrackControlsState { final Function(int)? onSwitchVersion; final ValueChanged? onSwitchQualityPreset; final ValueChanged? onSwitchAudioStreamId; + final ValueChanged? onSwitchSubtitleStreamId; final Function(AudioTrack)? onAudioTrackChanged; final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; @@ -71,6 +75,9 @@ class TrackControlsState { this.isTranscoding = false, this.sourceAudioTracks = const [], this.selectedAudioStreamId, + this.sourceSubtitleTracks = const [], + this.selectedSubtitleStreamId, + this.sourcePartId, this.sourceDurationMs, this.boxFitMode = 0, this.audioSyncOffset = 0, @@ -88,6 +95,7 @@ class TrackControlsState { this.onSwitchVersion, this.onSwitchQualityPreset, this.onSwitchAudioStreamId, + this.onSwitchSubtitleStreamId, this.onAudioTrackChanged, this.onSubtitleTrackChanged, this.onSecondarySubtitleTrackChanged, diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 8c5b7c9f..297e0d73 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -306,6 +306,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onScreenshot: _showScreenshotToast, currentPositionEpoch: widget.currentPositionEpoch, onLiveSeek: widget.onLiveSeek, + onSeekRequested: widget.onSeekRequested, ); // Let non-navigation keys (volume, etc.) pass through to the OS. if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored; diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 7c304b23..68f0aa32 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -28,6 +28,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { onSeekForward: () => unawaited(_seekByTime(forward: true)), onSeek: _throttledSeek, onSeekEnd: _finalizeSeek, + onSeekRequested: widget.onSeekRequested, getReplayIcon: getReplayIcon, getForwardIcon: getForwardIcon, onFocusActivity: _restartHideTimerIfPlaying, @@ -146,10 +147,12 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { int? newMediaIndex, TranscodeQualityPreset? newPreset, int? newAudioStreamId, + int? newSubtitleStreamId, }) async { final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex; final effectivePreset = newPreset ?? widget.selectedQualityPreset; final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId; + final effectiveSubtitleStreamId = newSubtitleStreamId ?? widget.selectedSubtitleStreamId; final effectiveMediaSourceId = effectiveMediaIndex >= 0 && effectiveMediaIndex < widget.availableVersions.length ? widget.availableVersions[effectiveMediaIndex].id : widget.selectedMediaSourceId; @@ -157,7 +160,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex; final isPresetChange = effectivePreset != widget.selectedQualityPreset; final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId; - if (!isVersionChange && !isPresetChange && !isAudioChange) { + final isSubtitleChange = + newSubtitleStreamId != null && effectiveSubtitleStreamId != widget.selectedSubtitleStreamId; + if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) { return; } @@ -176,6 +181,19 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { }); } + if (isSubtitleChange) { + final serverId = widget.metadata.serverId; + final partId = widget.sourcePartId; + if (serverId == null || partId == null || effectiveSubtitleStreamId == null) { + throw StateError('No Plex part available for subtitle stream selection'); + } + final client = context.getPlexClientForServer(serverId); + final saved = await client.selectStreams(partId, subtitleStreamID: effectiveSubtitleStreamId, allParts: true); + if (!saved) { + throw StateError('Failed to select subtitle stream'); + } + } + // Preserve session identifiers across the reload so Plex reuses the // transcode session rather than spinning up a new one. final sessionId = videoPlayerState?.playbackSessionIdentifier; diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index 850012f4..a461a7d0 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -54,7 +54,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { Future _seekToPosition(Duration position, {bool notifyCompletion = true}) async { final clamped = clampSeekPosition(widget.player, position); - await widget.player.seek(clamped); + await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (notifyCompletion && mounted) { widget.onSeekCompleted?.call(clamped); } @@ -68,7 +68,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { } final target = widget.player.state.position + delta; final clamped = clampSeekPosition(widget.player, target); - await widget.player.seek(clamped); + await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (notifyCompletion && mounted) { widget.onSeekCompleted?.call(clamped); } @@ -78,13 +78,16 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { if (!widget.player.state.playing && _rewindOnResume > 0) { final target = widget.player.state.position - Duration(seconds: _rewindOnResume); final clamped = clampSeekPosition(widget.player, target); - await widget.player.seek(clamped); + await (widget.onSeekRequested ?? widget.player.seek)(clamped); } await widget.player.playOrPause(); } /// Throttled seek for timeline slider - executes immediately then throttles to 200ms - void _throttledSeek(Duration position) => _seekThrottle([position]); + void _throttledSeek(Duration position) { + if (widget.isTranscoding) return; + _seekThrottle([position]); + } /// Finalizes the seek when user stops scrubbing the timeline void _finalizeSeek(Duration position) { diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 61bcd161..c57de4eb 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -93,7 +93,11 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { isTranscoding: widget.isTranscoding, sourceAudioTracks: widget.sourceAudioTracks, selectedAudioStreamId: widget.selectedAudioStreamId, + sourceSubtitleTracks: widget.sourceSubtitleTracks, + selectedSubtitleStreamId: widget.selectedSubtitleStreamId, ); + final canSwitchSourceSubtitles = + versionQuality.canSwitch && versionQuality.isTranscoding && widget.metadata.backend == MediaBackend.plex; return TrackControlsState( availableVersions: versionQuality.availableVersions, selectedMediaIndex: widget.selectedMediaIndex, @@ -102,6 +106,11 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { isTranscoding: versionQuality.isTranscoding, sourceAudioTracks: versionQuality.sourceAudioTracks, selectedAudioStreamId: versionQuality.selectedAudioStreamId, + sourceSubtitleTracks: canSwitchSourceSubtitles + ? versionQuality.sourceSubtitleTracks + : const [], + selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null, + sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null, sourceDurationMs: widget.metadata.durationMs, boxFitMode: widget.boxFitMode, audioSyncOffset: _audioSyncOffset, @@ -119,6 +128,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null, onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null, onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null, + onSwitchSubtitleStreamId: canSwitchSourceSubtitles + ? (id) => _switchVersionAndQuality(newSubtitleStreamId: id) + : null, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, @@ -177,6 +189,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { chapters: _chapters, chaptersLoaded: _chaptersLoaded, trackControlsState: trackControlsState, + onSeekRequested: widget.onSeekRequested, onSeekCompleted: widget.onSeekCompleted, hideChaptersAndQueue: hideChaptersAndQueue, ); diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index d4d37c34..48b63527 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -26,6 +26,7 @@ class ChapterSheet extends StatefulWidget { final List chapters; final bool chaptersLoaded; final String? serverId; // Server ID for the metadata these chapters belong to + final Future Function(Duration position)? onSeekRequested; final Function(Duration position)? onSeekCompleted; const ChapterSheet({ @@ -34,6 +35,7 @@ class ChapterSheet extends StatefulWidget { required this.chapters, required this.chaptersLoaded, this.serverId, + this.onSeekRequested, this.onSeekCompleted, }); @@ -52,7 +54,7 @@ class _ChapterSheetState extends State { Future _handleChapterTap(Duration position) async { final clamped = clampSeekPosition(widget.player, position); - await widget.player.seek(clamped); + await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (mounted) { widget.onSeekCompleted?.call(clamped); OverlaySheetController.of(context).close(); diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 925809d4..5a07d71b 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -33,6 +33,9 @@ class TrackSheet extends StatelessWidget { final List sourceAudioTracks; final int? selectedAudioStreamId; final ValueChanged? onSwitchAudioStreamId; + final List sourceSubtitleTracks; + final int? selectedSubtitleStreamId; + final ValueChanged? onSwitchSubtitleStreamId; /// Whether OpenSubtitles search is supported by the active server. Plex /// proxies the OpenSubtitles plugin; Jellyfin doesn't expose an @@ -53,6 +56,9 @@ class TrackSheet extends StatelessWidget { this.sourceAudioTracks = const [], this.selectedAudioStreamId, this.onSwitchAudioStreamId, + this.sourceSubtitleTracks = const [], + this.selectedSubtitleStreamId, + this.onSwitchSubtitleStreamId, this.subtitleSearchSupported = true, }); @@ -72,8 +78,9 @@ class TrackSheet extends StatelessWidget { final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal); final useSourceAudio = (isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null; + final useSourceSubtitles = isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null; final showAudio = useSourceAudio || playerAudioTracks.length > 1; - final showSubtitles = subtitleTracks.isNotEmpty; + final showSubtitles = useSourceSubtitles || subtitleTracks.isNotEmpty; final String title; final IconData icon; @@ -117,30 +124,43 @@ class TrackSheet extends StatelessWidget { ); } + Widget subtitleColumnFor(TrackSelection sel, bool showHeader) { + if (useSourceSubtitles) { + return _SourceSubtitleColumn( + tracks: sourceSubtitleTracks, + selectedStreamId: selectedSubtitleStreamId, + onSelected: onSwitchSubtitleStreamId!, + ratingKey: ratingKey, + serverId: serverId, + mediaTitle: mediaTitle, + onSubtitleDownloaded: onSubtitleDownloaded, + showHeader: showHeader, + subtitleSearchSupported: subtitleSearchSupported, + ); + } + return _SubtitleColumn( + tracks: subtitleTracks, + selection: sel, + player: player, + ratingKey: ratingKey, + serverId: serverId, + mediaTitle: mediaTitle, + onSubtitleDownloaded: onSubtitleDownloaded, + onTrackChanged: onSubtitleTrackChanged, + onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, + supportsSecondary: supportsSecondary, + showHeader: showHeader, + subtitleSearchSupported: subtitleSearchSupported, + ); + } + if (showAudio && showSubtitles) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))), VerticalDivider(width: 1, color: Theme.of(context).dividerColor), - Expanded( - child: FocusTraversalGroup( - child: _SubtitleColumn( - tracks: subtitleTracks, - selection: selection, - player: player, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - onSubtitleDownloaded: onSubtitleDownloaded, - onTrackChanged: onSubtitleTrackChanged, - onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, - supportsSecondary: supportsSecondary, - showHeader: true, - subtitleSearchSupported: subtitleSearchSupported, - ), - ), - ), + Expanded(child: FocusTraversalGroup(child: subtitleColumnFor(selection, true))), ], ); } @@ -149,20 +169,7 @@ class TrackSheet extends StatelessWidget { return audioColumnFor(selection, false); } - return _SubtitleColumn( - tracks: subtitleTracks, - selection: selection, - player: player, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - onSubtitleDownloaded: onSubtitleDownloaded, - onTrackChanged: onSubtitleTrackChanged, - onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, - supportsSecondary: supportsSecondary, - showHeader: false, - subtitleSearchSupported: subtitleSearchSupported, - ); + return subtitleColumnFor(selection, false); }, ), ); @@ -240,6 +247,112 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> { } } +class _SourceSubtitleColumn extends StatefulWidget { + final List tracks; + final int? selectedStreamId; + final ValueChanged onSelected; + final String ratingKey; + final String serverId; + final String? mediaTitle; + final Future Function()? onSubtitleDownloaded; + final bool showHeader; + final bool subtitleSearchSupported; + + const _SourceSubtitleColumn({ + required this.tracks, + required this.selectedStreamId, + required this.onSelected, + this.ratingKey = '', + this.serverId = '', + this.mediaTitle, + this.onSubtitleDownloaded, + required this.showHeader, + this.subtitleSearchSupported = true, + }); + + @override + State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState(); +} + +class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { + final _initialScroll = InitialItemScrollController(); + + @override + void dispose() { + _initialScroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final selectedId = _effectiveSelectedStreamId(); + final selectedIndex = selectedId == 0 ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1; + _initialScroll.maybeScrollTo(selectedIndex); + + return Column( + children: [ + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), + Expanded( + child: ListView.builder( + controller: _initialScroll.controller, + itemCount: widget.tracks.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: _initialScroll.firstItemKey, + isSelected: selectedId == 0, + onTap: () { + OverlaySheetController.of(context).close(); + widget.onSelected(0); + }, + ); + } + + final track = widget.tracks[index - 1]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: track.labelForIndex(index - 1), + isSelected: track.id == selectedId, + onTap: () { + OverlaySheetController.of(context).close(); + widget.onSelected(track.id); + }, + ); + }, + ), + ), + if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[ + Divider(height: 1, color: Theme.of(context).dividerColor), + FocusableListTile( + leading: const AppIcon(Symbols.search_rounded), + title: Text(t.videoControls.searchSubtitles), + onTap: () { + OverlaySheetController.of(context).push( + builder: (_) => SubtitleSearchSheet( + ratingKey: widget.ratingKey, + serverId: widget.serverId, + mediaTitle: widget.mediaTitle, + onSubtitleDownloaded: widget.onSubtitleDownloaded, + ), + ); + }, + ), + ], + ], + ); + } + + int _effectiveSelectedStreamId() { + final explicit = widget.selectedStreamId; + if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit; + for (final track in widget.tracks) { + if (track.selected) return track.id; + } + return 0; + } +} + class _AudioColumn extends StatefulWidget { final List tracks; final TrackSelection selection; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a873c6e7..9db39b90 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -91,6 +91,9 @@ Widget plexVideoControlsBuilder( bool isOfflinePlayback = false, List sourceAudioTracks = const [], int? selectedAudioStreamId, + List sourceSubtitleTracks = const [], + int? selectedSubtitleStreamId, + int? sourcePartId, VoidCallback? onTogglePIPMode, int boxFitMode = 0, VoidCallback? onCycleBoxFitMode, @@ -99,6 +102,7 @@ Widget plexVideoControlsBuilder( Function(AudioTrack)? onAudioTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged, Function(SubtitleTrack)? onSecondarySubtitleTrackChanged, + Future Function(Duration position)? onSeekRequested, Function(Duration position)? onSeekCompleted, VoidCallback? onBack, void Function({required bool skipAutoPlayCountdown})? onReachedEnd, @@ -136,6 +140,9 @@ Widget plexVideoControlsBuilder( isOfflinePlayback: isOfflinePlayback, sourceAudioTracks: sourceAudioTracks, selectedAudioStreamId: selectedAudioStreamId, + sourceSubtitleTracks: sourceSubtitleTracks, + selectedSubtitleStreamId: selectedSubtitleStreamId, + sourcePartId: sourcePartId, boxFitMode: boxFitMode, onTogglePIPMode: onTogglePIPMode, onCycleBoxFitMode: onCycleBoxFitMode, @@ -144,6 +151,7 @@ Widget plexVideoControlsBuilder( onAudioTrackChanged: onAudioTrackChanged, onSubtitleTrackChanged: onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged, + onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, onBack: onBack, onReachedEnd: onReachedEnd, @@ -174,6 +182,8 @@ Widget plexVideoControlsBuilder( bool isTranscoding, List sourceAudioTracks, int? selectedAudioStreamId, + List sourceSubtitleTracks, + int? selectedSubtitleStreamId, bool canSwitch, }) effectiveVersionQualityControls({ @@ -183,6 +193,8 @@ effectiveVersionQualityControls({ required bool isTranscoding, required List sourceAudioTracks, required int? selectedAudioStreamId, + required List sourceSubtitleTracks, + required int? selectedSubtitleStreamId, }) { if (isOfflinePlayback) { return ( @@ -191,6 +203,8 @@ effectiveVersionQualityControls({ isTranscoding: false, sourceAudioTracks: const [], selectedAudioStreamId: null, + sourceSubtitleTracks: const [], + selectedSubtitleStreamId: null, canSwitch: false, ); } @@ -200,6 +214,8 @@ effectiveVersionQualityControls({ isTranscoding: isTranscoding, sourceAudioTracks: sourceAudioTracks, selectedAudioStreamId: selectedAudioStreamId, + sourceSubtitleTracks: sourceSubtitleTracks, + selectedSubtitleStreamId: selectedSubtitleStreamId, canSwitch: true, ); } @@ -218,6 +234,9 @@ class PlexVideoControls extends StatefulWidget { final bool isOfflinePlayback; final List sourceAudioTracks; final int? selectedAudioStreamId; + final List sourceSubtitleTracks; + final int? selectedSubtitleStreamId; + final int? sourcePartId; final int boxFitMode; final VoidCallback? onTogglePIPMode; final VoidCallback? onCycleBoxFitMode; @@ -227,6 +246,10 @@ 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. + final Future Function(Duration position)? onSeekRequested; + /// Called when a seek operation completes (for Watch Together sync) final Function(Duration position)? onSeekCompleted; @@ -308,6 +331,9 @@ class PlexVideoControls extends StatefulWidget { this.isOfflinePlayback = false, this.sourceAudioTracks = const [], this.selectedAudioStreamId, + this.sourceSubtitleTracks = const [], + this.selectedSubtitleStreamId, + this.sourcePartId, this.boxFitMode = 0, this.onTogglePIPMode, this.onCycleBoxFitMode, @@ -316,6 +342,7 @@ class PlexVideoControls extends StatefulWidget { this.onAudioTrackChanged, this.onSubtitleTrackChanged, this.onSecondarySubtitleTrackChanged, + this.onSeekRequested, this.onSeekCompleted, this.onBack, this.onReachedEnd, @@ -710,6 +737,7 @@ class _PlexVideoControlsState extends State ), onSeek: _throttledSeek, onSeekEnd: _finalizeSeek, + onSeekRequested: widget.onSeekRequested, onSeekCompleted: widget.onSeekCompleted, // ignore: no-empty-block - play/pause handled by parent VideoControlsState onPlayPause: () {}, diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 6f02776d..56ca4a16 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -30,6 +30,7 @@ class ContentStrip extends StatefulWidget { final String? serverId; final bool showQueueTab; final Function(MediaItem)? onQueueItemSelected; + final Future Function(Duration position)? onSeekRequested; final Function(Duration position)? onSeekCompleted; /// Whether to use dpad/focus-based navigation (TV mode). @@ -50,6 +51,7 @@ class ContentStrip extends StatefulWidget { this.serverId, this.showQueueTab = false, this.onQueueItemSelected, + this.onSeekRequested, this.onSeekCompleted, this.useFocusNavigation = false, this.onNavigateUp, @@ -128,7 +130,7 @@ class ContentStripState extends State { Future _handleChapterTap(Duration position) async { final clamped = clampSeekPosition(widget.player, position); - await widget.player.seek(clamped); + await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (mounted) { widget.onSeekCompleted?.call(clamped); } diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 4a8a061c..9fae18e9 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -27,6 +27,7 @@ class TrackChapterControls extends StatelessWidget { final List chapters; final bool chaptersLoaded; final TrackControlsState trackControlsState; + final Future Function(Duration position)? onSeekRequested; final Function(Duration position)? onSeekCompleted; /// List of FocusNodes for the buttons (passed from parent for navigation) @@ -53,6 +54,7 @@ class TrackChapterControls extends StatelessWidget { required this.chapters, required this.chaptersLoaded, required this.trackControlsState, + this.onSeekRequested, this.onSeekCompleted, this.focusNodes, this.onFocusChange, @@ -243,7 +245,9 @@ class TrackChapterControls extends StatelessWidget { // Combined audio & subtitles button { final currentIndex = buttonIndex; - final hasSubs = _hasSubtitles(tracks); + final hasSourceSubs = + trackControlsState.sourceSubtitleTracks.isNotEmpty && trackControlsState.onSwitchSubtitleStreamId != null; + final hasSubs = _hasSubtitles(tracks) || hasSourceSubs; final selectedSub = player.state.track.subtitle; final hasActiveSubtitle = selectedSub != null && selectedSub.id != 'no'; final isHidden = hasSubs && hasActiveSubtitle && !subtitlesVisible; @@ -275,6 +279,9 @@ class TrackChapterControls extends StatelessWidget { sourceAudioTracks: trackControlsState.sourceAudioTracks, selectedAudioStreamId: trackControlsState.selectedAudioStreamId, onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId, + sourceSubtitleTracks: trackControlsState.sourceSubtitleTracks, + selectedSubtitleStreamId: trackControlsState.selectedSubtitleStreamId, + onSwitchSubtitleStreamId: trackControlsState.onSwitchSubtitleStreamId, subtitleSearchSupported: trackControlsState.subtitleSearchSupported, ), ) @@ -305,6 +312,7 @@ class TrackChapterControls extends StatelessWidget { chapters: chapters, chaptersLoaded: chaptersLoaded, serverId: serverId, + onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, ), ) diff --git a/test/media/media_source_info_test.dart b/test/media/media_source_info_test.dart new file mode 100644 index 00000000..57dd3d2e --- /dev/null +++ b/test/media/media_source_info_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_source_info.dart'; + +void main() { + group('MediaSubtitleTrack label', () { + test('prefers explicit source title over generated display title', () { + final track = MediaSubtitleTrack( + id: 401, + index: 0, + codec: 'srt', + languageCode: 'eng', + title: 'Forced', + displayTitle: 'English (SRT)', + selected: false, + forced: true, + ); + + expect(track.labelForIndex(0), 'Forced · ENG · SRT'); + expect(track.label, 'Forced · ENG · SRT'); + }); + + test('falls back to display title when source title is empty', () { + final track = MediaSubtitleTrack( + id: 402, + index: 1, + codec: 'ass', + languageCode: 'jpn', + title: ' ', + displayTitle: 'Japanese Signs/Songs', + selected: false, + forced: false, + ); + + expect(track.labelForIndex(1), 'Japanese Signs/Songs · JPN · ASS'); + }); + }); +} diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index ea803985..e424f2d2 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -104,6 +104,49 @@ void main() { }, ); }); + + test('MPV maps server-offset streams to absolute timeline positions', () async { + final calls = []; + + await _withMockChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) { + calls.add(call); + switch (call.method) { + case 'initialize': + return Future.value(true); + default: + return Future.value(null); + } + }, + testBody: () async { + final player = PlayerNative(); + try { + await player.open( + Media('https://example.test/transcode.mkv'), + timelineOffset: const Duration(seconds: 10), + timelineDuration: const Duration(seconds: 100), + ); + + expect(player.state.position, const Duration(seconds: 10)); + expect(player.state.duration, const Duration(seconds: 100)); + + player.handlePropertyChange('duration', 90.0); + expect(player.state.duration, const Duration(seconds: 100)); + + await player.seek(const Duration(seconds: 25)); + + final seekCall = calls.lastWhere((call) => call.method == 'command'); + final args = Map.from(seekCall.arguments as Map)['args'] as List; + expect(args, ['seek', '15.0', 'absolute']); + expect(player.state.position, const Duration(seconds: 25)); + } finally { + await player.dispose(); + } + }, + ); + }); }); } diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart new file mode 100644 index 00000000..de41a980 --- /dev/null +++ b/test/services/plex_playback_data_request_test.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/models/transcode_quality_preset.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + PlexClient makeClient(Future Function(http.Request request) handler) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: 'server-id', + httpClient: MockClient(handler), + ); + } + + MediaSourceInfo mediaInfoWithSubtitles(List subtitleTracks) { + return MediaSourceInfo( + videoUrl: 'https://plex.example.com/video.mkv', + audioTracks: const [], + subtitleTracks: subtitleTracks, + chapters: const [], + ); + } + + List buildTranscodeSubtitles(PlexClient client, List subtitleTracks) { + return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks)); + } + + test('playback metadata request includes streams for transcode sidecar subtitles', () async { + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + if (request.url.path != '/library/metadata/42') { + return http.Response('not found', 404); + } + + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'title': 'Movie', + 'Media': [ + { + 'id': 7, + 'container': 'mkv', + 'Part': [ + { + 'id': 99, + 'key': '/library/parts/99/file.mkv', + 'Stream': [ + {'streamType': 1, 'id': 300, 'codec': 'h264'}, + {'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true}, + { + 'streamType': 3, + 'id': 401, + 'index': 1, + 'codec': 'ass', + 'language': 'English', + 'languageCode': 'eng', + 'title': 'Signs/Songs', + 'selected': true, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final data = await client.getVideoPlaybackData('42'); + + expect(requests, hasLength(1)); + expect(requests.single.queryParameters['includeStreams'], '1'); + expect(data.mediaInfo?.subtitleTracks, hasLength(1)); + expect(data.mediaInfo?.subtitleTracks.single.id, 401); + expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue); + }); + + test('transcode subtitle sidecars only use real Plex stream keys', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final subtitles = buildTranscodeSubtitles(client, [ + MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false), + MediaSubtitleTrack( + id: 402, + codec: 'srt', + languageCode: 'eng', + selected: false, + forced: false, + key: '/library/streams/402', + external: true, + ), + ]); + + expect(subtitles, hasLength(1)); + expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token'); + }); + + test('selected internal text subtitles are not attached as external sidecars', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final subtitles = buildTranscodeSubtitles(client, [ + MediaSubtitleTrack( + id: 401, + codec: 'ass', + language: 'English', + languageCode: 'eng', + title: 'Signs/Songs', + selected: true, + forced: false, + ), + ]); + + expect(subtitles, isEmpty); + }); + + test('selected internal text subtitles are embedded in HTTP MKV transcode', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: MediaSubtitleTrack( + id: 401, + codec: 'ass', + languageCode: 'eng', + selected: true, + forced: false, + ), + ); + + expect(params['protocol'], 'http'); + expect(params['subtitles'], 'embedded'); + 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['X-Plex-Client-Profile-Extra'], + 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*)', + ), + ); + expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls'))); + expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile'))); + }); + + test('transcode start path uses HTTP start endpoint without token', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + 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, isNot(contains('X-Plex-Token'))); + }); + + test('unsupported embedded subtitles keep main transcode subtitles disabled', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + selectedSubtitleTrack: MediaSubtitleTrack( + id: 401, + codec: 'pgs', + languageCode: 'eng', + selected: true, + forced: false, + ), + ); + + expect(params['subtitles'], 'none'); + expect(params['protocol'], 'http'); + expect(params.containsKey('subtitleStreamID'), isFalse); + expect(params.containsKey('advancedSubtitles'), isFalse); + expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile'))); + }); + + test('bitmap embedded subtitles are skipped during transcode instead of burned', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + 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), + ]); + + expect(subtitles, isEmpty); + }); +} diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index be9e27a5..1abeaff9 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -225,6 +225,20 @@ void main() { expect(player.addSubtitleCalls.single.uri, 'https://example/c.srt'); }); + test('selects subtitle sidecars marked as default', () async { + final player = _FakePlayer(); + final mgr = _make(player: player); + addTearDown(mgr.dispose); + + const subs = [ + SubtitleTrack(id: 'selected', uri: 'https://example/selected.srt', isExternal: true, isDefault: true), + ]; + await mgr.addExternalSubtitles(subs); + + expect(player.addSubtitleCalls, hasLength(1)); + expect(player.addSubtitleCalls.single.select, isTrue); + }); + test('a player error on one entry does not prevent others from succeeding', () async { final player = _FakePlayer()..failAddSubtitleTimes = 1; final mgr = _make(player: player); diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index e61077f9..bf066519 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -13,6 +13,7 @@ void main() { test('clears switchable version and quality state during offline playback', () { final version = MediaVersion(id: 'v1', videoResolution: '1080'); final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false); + final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false); final result = effectiveVersionQualityControls( isOfflinePlayback: true, @@ -21,6 +22,8 @@ void main() { isTranscoding: true, sourceAudioTracks: [audio], selectedAudioStreamId: 1, + sourceSubtitleTracks: [subtitle], + selectedSubtitleStreamId: 2, ); expect(result.canSwitch, isFalse); @@ -29,11 +32,14 @@ void main() { expect(result.isTranscoding, isFalse); expect(result.sourceAudioTracks, isEmpty); expect(result.selectedAudioStreamId, isNull); + expect(result.sourceSubtitleTracks, isEmpty); + expect(result.selectedSubtitleStreamId, isNull); }); test('keeps switchable state during online playback', () { final version = MediaVersion(id: 'v1', videoResolution: '1080'); final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false); + final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false); final result = effectiveVersionQualityControls( isOfflinePlayback: false, @@ -42,6 +48,8 @@ void main() { isTranscoding: true, sourceAudioTracks: [audio], selectedAudioStreamId: 1, + sourceSubtitleTracks: [subtitle], + selectedSubtitleStreamId: 2, ); expect(result.canSwitch, isTrue); @@ -50,6 +58,8 @@ void main() { expect(result.isTranscoding, isTrue); expect(result.sourceAudioTracks, [audio]); expect(result.selectedAudioStreamId, 1); + expect(result.sourceSubtitleTracks, [subtitle]); + expect(result.selectedSubtitleStreamId, 2); }); });