From 71957022420f8b4a96478798a8b08e24ad4feba1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:38:15 +0200 Subject: [PATCH] fix(player): repair Plex transcode seeking close #1341 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 17 ++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 12 +- lib/mpv/player/platform/player_android.dart | 4 +- lib/screens/video_player/parts/build.dart | 1 + .../parts/episode_navigation.dart | 4 +- lib/screens/video_player/parts/lifecycle.dart | 2 +- .../video_player/parts/media_controls.dart | 4 +- .../video_player/parts/playback_open.dart | 4 +- .../video_player/parts/playback_prompts.dart | 6 +- .../video_player/parts/playback_services.dart | 22 +-- lib/screens/video_player/parts/seeking.dart | 42 ++--- lib/screens/video_player_screen.dart | 22 ++- lib/utils/player_utils.dart | 42 +++++ .../desktop_video_controls.dart | 61 ++++++++ .../video_controls/parts/playback_input.dart | 2 +- .../video_controls/video_controls.dart | 5 + .../widgets/video_timeline_bar.dart | 13 +- test/mpv/player_open_test.dart | 78 ++++++++++ test/utils/player_utils_test.dart | 143 +++++++++++++++++- test/widgets/video_controls_test.dart | 29 ++++ 20 files changed, 460 insertions(+), 53 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 49e1f559..5d7386e7 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -193,6 +193,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var pendingAudioRendererBounce: Boolean = false private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") } private var lastSeekable: Boolean? = null + private var forceSeekable: Boolean = false @Volatile private var disposing: Boolean = false private var pendingStartPositionMs: Long = 0L @@ -791,9 +792,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { lastPosition = startPositionMs lastDuration = 0L lastBufferedPosition = 0L - delegate?.onPropertyChange("time-pos", startPositionMs / 1000.0) - delegate?.onPropertyChange("duration", 0.0) - delegate?.onPropertyChange("demuxer-cache-time", 0.0) + // Dart already seeds the visible timeline before open. Emitting native + // zeroes here races server-offset Plex transcode restarts back to 0:00. delegate?.onPropertyChange("eof-reached", false) } @@ -805,10 +805,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun emitCurrentSeekable(force: Boolean = false) { val player = exoPlayer - val seekable = player?.isCurrentMediaItemSeekable == true && !currentMediaIsLive + val hasMedia = player?.currentMediaItem != null + val seekable = hasMedia && + !currentMediaIsLive && + (forceSeekable || player?.isCurrentMediaItemSeekable == true) emitSeekable(seekable, force) } + fun setForceSeekable(force: Boolean) { + if (forceSeekable == force) return + forceSeekable = force + emitCurrentSeekable(force = true) + } + // Player.Listener override fun onCues(cueGroup: CueGroup) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 36731e55..23eaf21e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -263,6 +263,7 @@ class ExoPlayerPlugin : val uri = call.argument("uri") val headers = call.argument>("headers") val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L + val hasStartPosition = call.argument("hasStartPosition") ?: (startPositionMs > 0L) val autoPlay = call.argument("autoPlay") ?: true val isLive = call.argument("isLive") ?: false val externalSubtitles = call.argument>>("externalSubtitles") @@ -286,7 +287,7 @@ class ExoPlayerPlugin : // MPV: Build loadfile command with options val startSeconds = startPositionMs / 1000.0 val options = mutableListOf() - options.add("start=$startSeconds") + options.add(if (hasStartPosition && startPositionMs > 0L) "start=$startSeconds" else "start=none") if (!autoPlay) options.add("pause=yes") options.add("sid=no") options.add("secondary-sid=no") @@ -296,7 +297,11 @@ class ExoPlayerPlugin : val optionsStr = options.joinToString(",") // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri - mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) { success -> + if (success && autoPlay) { + mpvCore?.setProperty("pause", "no") + } + } } else { playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) } @@ -652,6 +657,7 @@ class ExoPlayerPlugin : // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1) "sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes") + "force-seekable" -> playerCore?.setForceSeekable(value == "yes") } } @@ -876,7 +882,7 @@ class ExoPlayerPlugin : // Load media at the same position val startSeconds = positionMs / 1000.0 val options = mutableListOf() - options.add("start=$startSeconds") + options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none") headers?.forEach { (key, value) -> options.add("http-header-fields-append=$key: $value") } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 1c6c3ba1..949132cb 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -119,12 +119,12 @@ class PlayerAndroid extends PlayerBase { if (disposed) return; await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; + final hasStartPosition = media.start != null && startPosition > Duration.zero; // ExoPlayer reports Plex copyts transcodes in source-time coordinates, // unlike mpv which rebases them to zero. Do not add the timeline offset // again on Android ExoPlayer or seeks/progress jump to roughly 2x (#1221). configureTimeline(offset: Duration.zero, duration: timelineDuration); clearTracks(); - resetPlaybackProgress(media.start ?? timelineOffset); setSeekable(false); // Show the video layer @@ -134,6 +134,7 @@ class PlayerAndroid extends PlayerBase { 'uri': media.uri, 'headers': media.headers, 'startPositionMs': startPosition.inMilliseconds, + 'hasStartPosition': hasStartPosition, 'autoPlay': play, 'isLive': isLive, if (externalSubtitles != null && externalSubtitles.isNotEmpty) @@ -151,6 +152,7 @@ class PlayerAndroid extends PlayerBase { ) .toList(), }); + resetPlaybackProgress(media.start ?? timelineOffset); } @override diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 25ab91d3..9e510b87 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -287,6 +287,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, onSeekRequested: _seekPlayback, + onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!), onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, onReachedEnd: ({skipAutoPlayCountdown = false}) => diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 0e2abc06..839008a5 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -242,7 +242,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final currentSecondarySubtitleTrack = preserveCurrentTrackSelection ? currentPlayer.state.track.secondarySubtitle : null; - final wasPlayingBeforeReload = currentPlayer.state.playing; + final wasPlayingBeforeReload = _playbackIntentShouldPlay; var didOpenReplacement = false; // Capture context-dependent values before async gaps. The neutral @@ -502,7 +502,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // resumed session keeps reporting (and its eventual real stop sends). _progressTracker?.resumeAfterStoppedReport(); if (wasPlayingBeforeReload && mounted && player == currentPlayer) { - unawaited(currentPlayer.play()); + unawaited(_playWithPlaybackIntent(currentPlayer)); } } else if (_progressTracker == null && player == currentPlayer) { // The new file is playing and its session is committed — keep the diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 080caa75..b039f366 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -96,7 +96,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { _wasPlayingBeforeInactive = currentPlayer.state.isActive; if (_wasPlayingBeforeInactive) { try { - await currentPlayer.pause(); + await _pauseWithPlaybackIntent(currentPlayer); appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})'); } catch (e) { appLogger.w('Failed to pause video before background transition', error: e); diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index 22775226..f54dbeab 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 || _shouldRestartPlexTranscodeForSeek); + final canSeek = !widget.isLive && (currentPlayer.state.seekable || _usesPlexVodTranscodeSeekPolicy); if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; @@ -124,7 +124,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { : 'returning from inactive state'; try { await _seekBackForRewind(currentPlayer); - await currentPlayer.play(); + await _playWithPlaybackIntent(currentPlayer); appLogger.d('Video resumed after $resumeReason'); } catch (e) { appLogger.w('Failed to resume playback after $resumeReason', error: e); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 1b37c31d..a5f3a47d 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -287,10 +287,11 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { final trackManager = _trackManager; if (trackManager == null) return; appLogger.d('Frame rate matching: resuming playback after $reason'); + _playbackIntentShouldPlay = true; if (externalSubtitlePlan.requiresPostOpenAdd) { await trackManager.resumeAfterSubtitleLoad(); } else { - await currentPlayer.play(); + await _playWithPlaybackIntent(currentPlayer); } } @@ -416,6 +417,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { ); } finally { if (shouldResumeAfterSubtitleLoad()) { + _playbackIntentShouldPlay = true; await trackManager.resumeAfterSubtitleLoad(); } else if (applySelectionWhenResumeSkipped) { trackManager.waitingForExternalSubsTrackSelection = false; diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 04958c85..7804c57c 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -155,7 +155,8 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _onStillWatchingTimeout() { _unfocusStillWatchingPrompt(); - player?.pause(); + final currentPlayer = player; + if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer)); _setPlayerState(() { _showStillWatchingPrompt = false; }); @@ -173,7 +174,8 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _onStillWatchingPause() { _stillWatchingTimer?.cancel(); _unfocusStillWatchingPrompt(); - player?.pause(); + final currentPlayer = player; + if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer)); _setPlayerState(() { _showStillWatchingPrompt = false; }); diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 5162f664..54b74502 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -171,9 +171,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; if (event is PlayEvent) { + final currentPlayer = activePlayer!; appLogger.d('Media control: Play event received'); - _seekBackForRewind(activePlayer!); - activePlayer.play(); + unawaited(_seekBackForRewind(currentPlayer)); + unawaited(_playWithPlaybackIntent(currentPlayer)); _wasPlayingBeforeInactive = false; _updateMediaControlsPlaybackState(); } else if (event is PauseEvent) { @@ -182,15 +183,16 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } appLogger.d('Media control: Pause event received'); - activePlayer!.pause(); + unawaited(_pauseWithPlaybackIntent(activePlayer!)); _updateMediaControlsPlaybackState(); } else if (event is TogglePlayPauseEvent) { + final currentPlayer = activePlayer!; appLogger.d('Media control: Toggle play/pause event received'); - if (activePlayer!.state.isActive) { - activePlayer.pause(); + if (currentPlayer.state.isActive) { + unawaited(_pauseWithPlaybackIntent(currentPlayer)); } else { - _seekBackForRewind(activePlayer); - activePlayer.play(); + unawaited(_seekBackForRewind(currentPlayer)); + unawaited(_playWithPlaybackIntent(currentPlayer)); _wasPlayingBeforeInactive = false; } _updateMediaControlsPlaybackState(); @@ -266,7 +268,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { ); final currentPlayer = player; if (currentPlayer != null) { - unawaited(currentPlayer.pause()); + unawaited(_pauseWithPlaybackIntent(currentPlayer)); } unawaited(_setWakelock(false)); return; @@ -339,7 +341,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } try { - await currentPlayer.pause(); + await _pauseWithPlaybackIntent(currentPlayer); appLogger.d('Video paused after Apple audio session $reason'); } catch (e) { appLogger.w('Failed to pause after Apple audio session $reason', error: e); @@ -366,7 +368,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (expectedPlayer.state.isActive) return; try { - await expectedPlayer.play(); + await _playWithPlaybackIntent(expectedPlayer); _wasPlayingBeforeInactive = false; appLogger.d('Video resumed after Apple audio session $reason'); } catch (e) { diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 2a22d743..ef8aa6df 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -6,12 +6,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { if (!mounted || currentPlayer == null) return; final target = clampSeekPosition(currentPlayer, position); - if (!_shouldRestartPlexTranscodeForSeek) { - await currentPlayer.seek(target); - return; - } - - if (_canSeekWithinCurrentTranscodeBuffer(currentPlayer, target)) { + if (_plexTranscodeSeekAction(currentPlayer, target) == PlexTranscodeSeekAction.nativeSeek) { await currentPlayer.seek(target); return; } @@ -19,7 +14,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { await _restartPlexTranscodeAt(target); } - bool get _shouldRestartPlexTranscodeForSeek { + bool get _usesPlexVodTranscodeSeekPolicy { return _isTranscoding && !widget.isLive && !_isOfflinePlayback && @@ -27,15 +22,22 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { _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; + 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 { @@ -52,8 +54,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { } final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds); - final wasPlaying = currentPlayer.state.playing; - final nextTranscodeSessionId = generateSessionIdentifier(); + final shouldResumePlayback = _playbackIntentShouldPlay; final offlineWatchService = context.read(); final playbackResolver = PlaybackSourceResolver( serverManager: context.read().serverManager, @@ -61,7 +62,6 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { ); try { - _playbackTranscodeSessionId = nextTranscodeSessionId; final playbackContext = await playbackResolver.resolve( metadata: replacementMetadata, selectedMediaIndex: _effectiveSelectedMediaIndex, @@ -91,7 +91,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { player: currentPlayer, externalSubtitles: result.externalSubtitles, ); - final shouldAutoPlay = wasPlaying && externalSubtitlePlan.canStartBeforeTrackSetup; + final shouldAutoPlay = shouldResumePlayback && externalSubtitlePlan.canStartBeforeTrackSetup; final didOpen = await _openMediaOnPlayer( player: currentPlayer, @@ -148,7 +148,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { externalSubtitlePlan: externalSubtitlePlan, // A restart while paused must stay paused — selection is still // applied through the resume-skipped branch. - shouldResumeAfterSubtitleLoad: () => wasPlaying && mounted && player == currentPlayer, + shouldResumeAfterSubtitleLoad: () => shouldResumePlayback && mounted && player == currentPlayer, applySelectionWhenResumeSkipped: true, ); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e7b6ae69..e824f3b6 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -267,6 +267,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // entry guards make reload / transcode-restart / channel-switch mutually // exclusive instead of relying on three independent booleans. _PlaybackTransition _playbackTransition = _PlaybackTransition.idle; + bool _playbackIntentShouldPlay = true; bool _showPlayNextDialog = false; bool _isPhone = false; @@ -484,6 +485,21 @@ class VideoPlayerScreenState extends State with WidgetsBindin return mounted && player == currentPlayer && _playbackGeneration == generation; } + Future _playWithPlaybackIntent(Player currentPlayer) { + _playbackIntentShouldPlay = true; + return currentPlayer.play(); + } + + Future _pauseWithPlaybackIntent(Player currentPlayer) { + _playbackIntentShouldPlay = false; + return currentPlayer.pause(); + } + + Future _playOrPauseWithPlaybackIntent(Player currentPlayer) { + _playbackIntentShouldPlay = !currentPlayer.state.playing; + return currentPlayer.playOrPause(); + } + final ValueNotifier _isBuffering = ValueNotifier(false); final ValueNotifier _hasFirstFrame = ValueNotifier(false); final ValueNotifier _isExiting = ValueNotifier(false); @@ -867,7 +883,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); // Restart sleep timer if we're starting a new playback session - SleepTimerService().restartIfNeeded(() => currentPlayer.pause()); + SleepTimerService().restartIfNeeded(() => unawaited(_pauseWithPlaybackIntent(currentPlayer))); // Enable wakelock to prevent screen from turning off during playback unawaited(_setWakelock(true)); @@ -1065,7 +1081,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final exitPosition = currentPlayer.state.position; if (currentPlayer.state.isActive) { try { - await currentPlayer.pause(); + await _pauseWithPlaybackIntent(currentPlayer); } catch (e, st) { appLogger.w('Failed to pause player during route exit', error: e, stackTrace: st); } @@ -1347,7 +1363,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _seekBackForRewind(currentPlayer); if (!mounted || player != currentPlayer) return; } - await currentPlayer.playOrPause(); + await _playOrPauseWithPlaybackIntent(currentPlayer); } catch (e, st) { appLogger.w('Apple TV remote play/pause failed', error: e, stackTrace: st); } diff --git a/lib/utils/player_utils.dart b/lib/utils/player_utils.dart index 000c49a0..0053a2a6 100644 --- a/lib/utils/player_utils.dart +++ b/lib/utils/player_utils.dart @@ -1,6 +1,11 @@ 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; @@ -12,3 +17,40 @@ 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/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 9ba5058f..18ba9749 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -190,7 +190,13 @@ class DesktopVideoControlsState extends State { // Preview thumbnail during sustained dpad/keyboard seeking bool _showKeyRepeatThumbnail = false; Timer? _keyRepeatThumbnailTimer; + Timer? _timelineSeekDebounceTimer; + Timer? _timelinePreviewClearTimer; + Duration? _timelinePreviewPosition; + Duration? _lastFlushedTimelinePreviewPosition; static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); + static const _timelineSeekDebounce = Duration(milliseconds: 350); + static const _timelinePreviewClearDelay = Duration(seconds: 2); // Content strip state bool _contentStripVisible = false; @@ -236,6 +242,8 @@ class DesktopVideoControlsState extends State { @override void dispose() { _keyRepeatThumbnailTimer?.cancel(); + _timelineSeekDebounceTimer?.cancel(); + _timelinePreviewClearTimer?.cancel(); _prevItemFocusNode.dispose(); _prevChapterFocusNode.dispose(); _skipBackFocusNode.dispose(); @@ -307,6 +315,7 @@ class DesktopVideoControlsState extends State { widget.onFocusActivity?.call(); } else { // Reset progressive seek state when timeline loses focus + _flushTimelinePreviewSeek(); _resetSeekState(); } } @@ -460,6 +469,40 @@ class DesktopVideoControlsState extends State { } } + void _clearTimelinePreviewIfStill(Duration target) { + if (!mounted || _timelinePreviewPosition != target) return; + setState(() => _timelinePreviewPosition = null); + } + + void _scheduleTimelinePreviewClear(Duration target) { + _timelinePreviewClearTimer?.cancel(); + _timelinePreviewClearTimer = Timer(_timelinePreviewClearDelay, () => _clearTimelinePreviewIfStill(target)); + } + + void _flushTimelinePreviewSeek() { + final target = _timelinePreviewPosition; + _timelineSeekDebounceTimer?.cancel(); + _timelineSeekDebounceTimer = null; + if (target == null) return; + if (_lastFlushedTimelinePreviewPosition == target) return; + _lastFlushedTimelinePreviewPosition = target; + widget.onSeekEnd(target); + _scheduleTimelinePreviewClear(target); + } + + void _scheduleTimelinePreviewSeekFlush() { + _timelineSeekDebounceTimer?.cancel(); + _timelineSeekDebounceTimer = Timer(_timelineSeekDebounce, _flushTimelinePreviewSeek); + } + + void _setTimelinePreviewPosition(Duration position) { + _timelinePreviewClearTimer?.cancel(); + _timelinePreviewClearTimer = null; + if (_timelinePreviewPosition == position) return; + _lastFlushedTimelinePreviewPosition = null; + setState(() => _timelinePreviewPosition = position); + } + /// Show the timeline preview thumbnail during sustained key-repeat seeking. /// Arms a short timer that hides the thumbnail once repeats stop. void _triggerKeyRepeatThumbnail() { @@ -493,6 +536,11 @@ class DesktopVideoControlsState extends State { // Handle key release to reset progressive seek state if (event is KeyUpEvent) { if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { + if (_trackControlsState.isTranscoding) { + _flushTimelinePreviewSeek(); + _resetSeekState(); + return KeyEventResult.handled; + } _resetSeekState(); } return KeyEventResult.ignored; @@ -507,6 +555,7 @@ class DesktopVideoControlsState extends State { // UP arrow - hide controls and reset seek state if (key == LogicalKeyboardKey.arrowUp) { + _flushTimelinePreviewSeek(); _resetSeekState(); widget.onHideControls?.call(); return KeyEventResult.handled; @@ -514,6 +563,7 @@ class DesktopVideoControlsState extends State { // DOWN arrow - move focus to play/pause button and reset seek state if (key == LogicalKeyboardKey.arrowDown) { + _flushTimelinePreviewSeek(); _resetSeekState(); _playPauseFocusNode.requestFocus(); widget.onFocusActivity?.call(); @@ -560,6 +610,16 @@ class DesktopVideoControlsState extends State { // Clamp to valid range final clampedPosition = Duration(milliseconds: newPosition.inMilliseconds.clamp(0, duration.inMilliseconds)); + if (_trackControlsState.isTranscoding) { + final previewBase = _timelinePreviewPosition ?? position; + final previewPosition = isForward ? previewBase + step : previewBase - step; + final clampedPreview = Duration(milliseconds: previewPosition.inMilliseconds.clamp(0, duration.inMilliseconds)); + _setTimelinePreviewPosition(clampedPreview); + _scheduleTimelinePreviewSeekFlush(); + widget.onFocusActivity?.call(); + return KeyEventResult.handled; + } + widget.onSeek(clampedPosition); widget.onFocusActivity?.call(); return KeyEventResult.handled; @@ -730,6 +790,7 @@ class DesktopVideoControlsState extends State { enabled: canInteract, thumbnailDataBuilder: widget.thumbnailDataBuilder, showKeyRepeatThumbnail: _showKeyRepeatThumbnail, + previewPosition: _timelinePreviewPosition, ), ], // Row 2: Playback controls and options diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index 45dd572c..e61cceda 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -84,7 +84,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { final clamped = clampSeekPosition(widget.player, target); await (widget.onSeekRequested ?? widget.player.seek)(clamped); } - await widget.player.playOrPause(); + await (widget.onPlayPauseRequested ?? widget.player.playOrPause)(); } /// Throttled seek for timeline slider - executes immediately then throttles to 200ms diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 366bf5a5..10eb2191 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -204,6 +204,10 @@ class PlexVideoControls extends StatefulWidget { /// the server-side transcode session at the requested absolute timestamp. final Future Function(Duration position)? onSeekRequested; + /// Called for app-level play/pause requests so the owning screen can track + /// user playback intent separately from transient buffering state. + final Future Function()? onPlayPauseRequested; + /// Called when a seek operation completes (for Watch Together sync) final Function(Duration position)? onSeekCompleted; @@ -307,6 +311,7 @@ class PlexVideoControls extends StatefulWidget { this.onSubtitleTrackChanged, this.onSecondarySubtitleTrackChanged, this.onSeekRequested, + this.onPlayPauseRequested, this.onSeekCompleted, this.onBack, this.onReachedEnd, diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 989e0fb0..ccb61e0a 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -47,6 +47,9 @@ class VideoTimelineBar extends StatelessWidget { /// (used during sustained dpad/keyboard key-repeat seeking). final bool showKeyRepeatThumbnail; + /// Optional UI-only position used while a remote/keyboard seek is pending. + final Duration? previewPosition; + const VideoTimelineBar({ super.key, required this.player, @@ -65,6 +68,7 @@ class VideoTimelineBar extends StatelessWidget { this.showFinishTime = false, this.thumbnailDataBuilder, this.showKeyRepeatThumbnail = false, + this.previewPosition, }); @override @@ -81,8 +85,9 @@ class VideoTimelineBar extends StatelessWidget { stream: player.streams.bufferRanges, initialData: player.state.bufferRanges, builder: (context, bufferRangesSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; + final rawPosition = positionSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero; + final position = previewPosition == null ? rawPosition : clampDuration(previewPosition!, duration); final bufferRanges = bufferRangesSnapshot.data ?? const []; final remaining = position - duration; // We want this to be negative @@ -97,6 +102,12 @@ class VideoTimelineBar extends StatelessWidget { ); } + static Duration clampDuration(Duration position, Duration duration) { + if (position.isNegative) return Duration.zero; + if (duration > Duration.zero && position > duration) return duration; + return position; + } + Widget _buildHorizontalLayout( Duration position, Duration duration, diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 06142fe3..d3f5ec7a 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -165,6 +165,7 @@ void main() { final openCall = calls.singleWhere((call) => call.method == 'open'); final openArgs = Map.from(openCall.arguments as Map); expect(openArgs['startPositionMs'], 0); + expect(openArgs['hasStartPosition'], isFalse); await Future.delayed(const Duration(milliseconds: 260)); player.handlePropertyChange('time-pos', 2058.0); @@ -183,6 +184,83 @@ void main() { ); }); + test('ExoPlayer source-offset open keeps timeline offset after stale native zero position', () async { + final calls = []; + late PlayerAndroid player; + + await _withMockChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) { + calls.add(call); + switch (call.method) { + case 'initialize': + return Future.value(true); + case 'open': + player.handlePropertyChange('time-pos', 0.0); + player.handlePropertyChange('duration', 0.0); + player.handlePropertyChange('demuxer-cache-time', 0.0); + return Future.value(null); + default: + return Future.value(null); + } + }, + testBody: () async { + player = PlayerAndroid(); + try { + const timelineStart = Duration(seconds: 2058); + const timelineDuration = Duration(seconds: 2903); + await player.open( + Media('https://example.test/transcode.mkv'), + timelineOffset: timelineStart, + timelineDuration: timelineDuration, + ); + + expect(player.state.position, timelineStart); + expect(player.state.duration, timelineDuration); + + final openCall = calls.singleWhere((call) => call.method == 'open'); + final openArgs = Map.from(openCall.arguments as Map); + expect(openArgs['startPositionMs'], 0); + expect(openArgs['hasStartPosition'], isFalse); + } finally { + await player.dispose(); + } + }, + ); + }); + + test('ExoPlayer marks explicit non-zero media starts for native fallback', () async { + final calls = []; + + await _withMockChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_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 = PlayerAndroid(); + try { + await player.open(Media('https://example.test/movie.mkv', start: const Duration(seconds: 12))); + + final openCall = calls.singleWhere((call) => call.method == 'open'); + final openArgs = Map.from(openCall.arguments as Map); + expect(openArgs['startPositionMs'], 12000); + expect(openArgs['hasStartPosition'], isTrue); + } finally { + await player.dispose(); + } + }, + ); + }); + test('MPV clears stale Dart track state before opening new media', () async { await _withMockChannels( methodChannelName: 'com.plezy/mpv_player', diff --git a/test/utils/player_utils_test.dart b/test/utils/player_utils_test.dart index 253754ea..96bfae27 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 Player, PlayerState; +import 'package:plezy/mpv/mpv.dart' show BufferRange, Player, PlayerState; import 'package:plezy/utils/player_utils.dart'; void main() { @@ -33,6 +33,147 @@ 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('uses native seek near the start of a buffer range', () { + expect( + resolvePlexTranscodeSeekAction( + currentPosition: const Duration(seconds: 30), + target: const Duration(milliseconds: 29500), + bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))], + ), + PlexTranscodeSeekAction.nativeSeek, + ); + }); + + 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('does not treat a flat buffer end as a local seekable range', () { + expect( + resolvePlexTranscodeSeekAction( + currentPosition: const Duration(seconds: 30), + target: const Duration(seconds: 45), + bufferRanges: const [], + ), + 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 d0cf8c83..c3e75b5f 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -13,6 +13,9 @@ import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart'; import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart'; import 'package:plezy/widgets/video_controls/widgets/sync_offset_control.dart'; import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart'; +import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart'; + +import '../test_helpers/watch_together_fakes.dart'; const _testTokens = MonoTokens( radiusSm: 8, @@ -419,6 +422,32 @@ void main() { expect(slider.max, 0.0); }); + testWidgets('timeline bar displays pending preview position while player position is stale', (tester) async { + final player = FakeSyncPlayer(position: const Duration(minutes: 1), duration: const Duration(minutes: 10)); + addTearDown(player.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: VideoTimelineBar( + player: player, + chapters: const [], + chaptersLoaded: true, + previewPosition: const Duration(minutes: 4), + onSeek: (_) {}, + onSeekEnd: (_) {}, + ), + ), + ), + ), + ); + + final slider = tester.widget(find.byType(Slider)); + expect(slider.value, const Duration(minutes: 4).inMilliseconds.toDouble()); + }); + Future pumpScrubSlider( WidgetTester tester, { required List seeks,