fix(player): repair Plex transcode seeking

close #1341
This commit is contained in:
edde746
2026-06-14 23:39:05 +02:00
parent b459c5150c
commit 7195702242
20 changed files with 460 additions and 53 deletions
@@ -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) {
@@ -263,6 +263,7 @@ class ExoPlayerPlugin :
val uri = call.argument<String>("uri")
val headers = call.argument<Map<String, String>>("headers")
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
val hasStartPosition = call.argument<Boolean>("hasStartPosition") ?: (startPositionMs > 0L)
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
@@ -286,7 +287,7 @@ class ExoPlayerPlugin :
// MPV: Build loadfile command with options
val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>()
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<String>()
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")
}
+3 -1
View File
@@ -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
@@ -287,6 +287,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
onSeekRequested: _seekPlayback,
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
@@ -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
@@ -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);
@@ -79,7 +79,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
final playbackState = context.read<PlaybackStateProvider>();
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);
@@ -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;
@@ -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;
});
@@ -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) {
+21 -21
View File
@@ -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<void> _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<OfflineWatchSyncService>();
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().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,
);
}
+19 -3
View File
@@ -267,6 +267,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
return mounted && player == currentPlayer && _playbackGeneration == generation;
}
Future<void> _playWithPlaybackIntent(Player currentPlayer) {
_playbackIntentShouldPlay = true;
return currentPlayer.play();
}
Future<void> _pauseWithPlaybackIntent(Player currentPlayer) {
_playbackIntentShouldPlay = false;
return currentPlayer.pause();
}
Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) {
_playbackIntentShouldPlay = !currentPlayer.state.playing;
return currentPlayer.playOrPause();
}
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
@@ -867,7 +883,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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);
}
+42
View File
@@ -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<BufferRange> bufferRanges,
bool allowBufferedNativeSeek = true,
Duration rangeStartTolerance = plexTranscodeSeekRangeStartTolerance,
Duration rangeEndGuard = plexTranscodeSeekRangeEndGuard,
Duration noopTolerance = plexTranscodeSeekNoopTolerance,
}) {
final validRanges = bufferRanges.where((range) => range.end >= range.start).toList();
if (allowBufferedNativeSeek &&
_isInAnyBufferedSeekRange(target, validRanges, startTolerance: rangeStartTolerance, endGuard: rangeEndGuard)) {
return PlexTranscodeSeekAction.nativeSeek;
}
if ((target - currentPosition).abs() <= noopTolerance) {
return PlexTranscodeSeekAction.nativeSeek;
}
return PlexTranscodeSeekAction.restartTranscode;
}
bool _isInAnyBufferedSeekRange(
Duration target,
List<BufferRange> ranges, {
required Duration startTolerance,
required Duration endGuard,
}) {
for (final range in ranges) {
if (target >= range.start - startTolerance && target <= range.end - endGuard) return true;
}
return false;
}
@@ -190,7 +190,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// 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<DesktopVideoControls> {
@override
void dispose() {
_keyRepeatThumbnailTimer?.cancel();
_timelineSeekDebounceTimer?.cancel();
_timelinePreviewClearTimer?.cancel();
_prevItemFocusNode.dispose();
_prevChapterFocusNode.dispose();
_skipBackFocusNode.dispose();
@@ -307,6 +315,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
widget.onFocusActivity?.call();
} else {
// Reset progressive seek state when timeline loses focus
_flushTimelinePreviewSeek();
_resetSeekState();
}
}
@@ -460,6 +469,40 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
}
}
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<DesktopVideoControls> {
// 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<DesktopVideoControls> {
// 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<DesktopVideoControls> {
// 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<DesktopVideoControls> {
// 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<DesktopVideoControls> {
enabled: canInteract,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
showKeyRepeatThumbnail: _showKeyRepeatThumbnail,
previewPosition: _timelinePreviewPosition,
),
],
// Row 2: Playback controls and options
@@ -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
@@ -204,6 +204,10 @@ class PlexVideoControls extends StatefulWidget {
/// the server-side transcode session at the requested absolute timestamp.
final Future<void> 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<void> 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,
@@ -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,
+78
View File
@@ -165,6 +165,7 @@ void main() {
final openCall = calls.singleWhere((call) => call.method == 'open');
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], 0);
expect(openArgs['hasStartPosition'], isFalse);
await Future<void>.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 = <MethodCall>[];
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<Object?, Object?>.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 = <MethodCall>[];
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<Object?, Object?>.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',
+142 -1
View File
@@ -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 {
+29
View File
@@ -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<Slider>(find.byType(Slider));
expect(slider.value, const Duration(minutes: 4).inMilliseconds.toDouble());
});
Future<void> pumpScrubSlider(
WidgetTester tester, {
required List<Duration> seeks,