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 var pendingAudioRendererBounce: Boolean = false
private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") } private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") }
private var lastSeekable: Boolean? = null private var lastSeekable: Boolean? = null
private var forceSeekable: Boolean = false
@Volatile private var disposing: Boolean = false @Volatile private var disposing: Boolean = false
private var pendingStartPositionMs: Long = 0L private var pendingStartPositionMs: Long = 0L
@@ -791,9 +792,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
lastPosition = startPositionMs lastPosition = startPositionMs
lastDuration = 0L lastDuration = 0L
lastBufferedPosition = 0L lastBufferedPosition = 0L
delegate?.onPropertyChange("time-pos", startPositionMs / 1000.0) // Dart already seeds the visible timeline before open. Emitting native
delegate?.onPropertyChange("duration", 0.0) // zeroes here races server-offset Plex transcode restarts back to 0:00.
delegate?.onPropertyChange("demuxer-cache-time", 0.0)
delegate?.onPropertyChange("eof-reached", false) delegate?.onPropertyChange("eof-reached", false)
} }
@@ -805,10 +805,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun emitCurrentSeekable(force: Boolean = false) { private fun emitCurrentSeekable(force: Boolean = false) {
val player = exoPlayer 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) emitSeekable(seekable, force)
} }
fun setForceSeekable(force: Boolean) {
if (forceSeekable == force) return
forceSeekable = force
emitCurrentSeekable(force = true)
}
// Player.Listener // Player.Listener
override fun onCues(cueGroup: CueGroup) { override fun onCues(cueGroup: CueGroup) {
@@ -263,6 +263,7 @@ class ExoPlayerPlugin :
val uri = call.argument<String>("uri") val uri = call.argument<String>("uri")
val headers = call.argument<Map<String, String>>("headers") val headers = call.argument<Map<String, String>>("headers")
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
val hasStartPosition = call.argument<Boolean>("hasStartPosition") ?: (startPositionMs > 0L)
val autoPlay = call.argument<Boolean>("autoPlay") ?: true val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles") val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
@@ -286,7 +287,7 @@ class ExoPlayerPlugin :
// MPV: Build loadfile command with options // MPV: Build loadfile command with options
val startSeconds = startPositionMs / 1000.0 val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>() 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") if (!autoPlay) options.add("pause=yes")
options.add("sid=no") options.add("sid=no")
options.add("secondary-sid=no") options.add("secondary-sid=no")
@@ -296,7 +297,11 @@ class ExoPlayerPlugin :
val optionsStr = options.joinToString(",") val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri 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 { } else {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) 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 // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1) // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes") "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 // Load media at the same position
val startSeconds = positionMs / 1000.0 val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>() val options = mutableListOf<String>()
options.add("start=$startSeconds") options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none")
headers?.forEach { (key, value) -> headers?.forEach { (key, value) ->
options.add("http-header-fields-append=$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; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
final startPosition = media.start ?? Duration.zero; final startPosition = media.start ?? Duration.zero;
final hasStartPosition = media.start != null && startPosition > Duration.zero;
// ExoPlayer reports Plex copyts transcodes in source-time coordinates, // ExoPlayer reports Plex copyts transcodes in source-time coordinates,
// unlike mpv which rebases them to zero. Do not add the timeline offset // 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). // again on Android ExoPlayer or seeks/progress jump to roughly 2x (#1221).
configureTimeline(offset: Duration.zero, duration: timelineDuration); configureTimeline(offset: Duration.zero, duration: timelineDuration);
clearTracks(); clearTracks();
resetPlaybackProgress(media.start ?? timelineOffset);
setSeekable(false); setSeekable(false);
// Show the video layer // Show the video layer
@@ -134,6 +134,7 @@ class PlayerAndroid extends PlayerBase {
'uri': media.uri, 'uri': media.uri,
'headers': media.headers, 'headers': media.headers,
'startPositionMs': startPosition.inMilliseconds, 'startPositionMs': startPosition.inMilliseconds,
'hasStartPosition': hasStartPosition,
'autoPlay': play, 'autoPlay': play,
'isLive': isLive, 'isLive': isLive,
if (externalSubtitles != null && externalSubtitles.isNotEmpty) if (externalSubtitles != null && externalSubtitles.isNotEmpty)
@@ -151,6 +152,7 @@ class PlayerAndroid extends PlayerBase {
) )
.toList(), .toList(),
}); });
resetPlaybackProgress(media.start ?? timelineOffset);
} }
@override @override
@@ -287,6 +287,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
onSeekRequested: _seekPlayback, onSeekRequested: _seekPlayback,
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
onSeekCompleted: _notifyWatchTogetherSeek, onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton, onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) => onReachedEnd: ({skipAutoPlayCountdown = false}) =>
@@ -242,7 +242,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final currentSecondarySubtitleTrack = preserveCurrentTrackSelection final currentSecondarySubtitleTrack = preserveCurrentTrackSelection
? currentPlayer.state.track.secondarySubtitle ? currentPlayer.state.track.secondarySubtitle
: null; : null;
final wasPlayingBeforeReload = currentPlayer.state.playing; final wasPlayingBeforeReload = _playbackIntentShouldPlay;
var didOpenReplacement = false; var didOpenReplacement = false;
// Capture context-dependent values before async gaps. The neutral // 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). // resumed session keeps reporting (and its eventual real stop sends).
_progressTracker?.resumeAfterStoppedReport(); _progressTracker?.resumeAfterStoppedReport();
if (wasPlayingBeforeReload && mounted && player == currentPlayer) { if (wasPlayingBeforeReload && mounted && player == currentPlayer) {
unawaited(currentPlayer.play()); unawaited(_playWithPlaybackIntent(currentPlayer));
} }
} else if (_progressTracker == null && player == currentPlayer) { } else if (_progressTracker == null && player == currentPlayer) {
// The new file is playing and its session is committed — keep the // The new file is playing and its session is committed — keep the
@@ -96,7 +96,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
_wasPlayingBeforeInactive = currentPlayer.state.isActive; _wasPlayingBeforeInactive = currentPlayer.state.isActive;
if (_wasPlayingBeforeInactive) { if (_wasPlayingBeforeInactive) {
try { try {
await currentPlayer.pause(); await _pauseWithPlaybackIntent(currentPlayer);
appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})'); appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})');
} catch (e) { } catch (e) {
appLogger.w('Failed to pause video before background transition', error: 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 playbackState = context.read<PlaybackStateProvider>();
final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; 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; if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return;
@@ -124,7 +124,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
: 'returning from inactive state'; : 'returning from inactive state';
try { try {
await _seekBackForRewind(currentPlayer); await _seekBackForRewind(currentPlayer);
await currentPlayer.play(); await _playWithPlaybackIntent(currentPlayer);
appLogger.d('Video resumed after $resumeReason'); appLogger.d('Video resumed after $resumeReason');
} catch (e) { } catch (e) {
appLogger.w('Failed to resume playback after $resumeReason', error: e); appLogger.w('Failed to resume playback after $resumeReason', error: e);
@@ -287,10 +287,11 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
final trackManager = _trackManager; final trackManager = _trackManager;
if (trackManager == null) return; if (trackManager == null) return;
appLogger.d('Frame rate matching: resuming playback after $reason'); appLogger.d('Frame rate matching: resuming playback after $reason');
_playbackIntentShouldPlay = true;
if (externalSubtitlePlan.requiresPostOpenAdd) { if (externalSubtitlePlan.requiresPostOpenAdd) {
await trackManager.resumeAfterSubtitleLoad(); await trackManager.resumeAfterSubtitleLoad();
} else { } else {
await currentPlayer.play(); await _playWithPlaybackIntent(currentPlayer);
} }
} }
@@ -416,6 +417,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
); );
} finally { } finally {
if (shouldResumeAfterSubtitleLoad()) { if (shouldResumeAfterSubtitleLoad()) {
_playbackIntentShouldPlay = true;
await trackManager.resumeAfterSubtitleLoad(); await trackManager.resumeAfterSubtitleLoad();
} else if (applySelectionWhenResumeSkipped) { } else if (applySelectionWhenResumeSkipped) {
trackManager.waitingForExternalSubsTrackSelection = false; trackManager.waitingForExternalSubsTrackSelection = false;
@@ -155,7 +155,8 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
void _onStillWatchingTimeout() { void _onStillWatchingTimeout() {
_unfocusStillWatchingPrompt(); _unfocusStillWatchingPrompt();
player?.pause(); final currentPlayer = player;
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
_setPlayerState(() { _setPlayerState(() {
_showStillWatchingPrompt = false; _showStillWatchingPrompt = false;
}); });
@@ -173,7 +174,8 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
void _onStillWatchingPause() { void _onStillWatchingPause() {
_stillWatchingTimer?.cancel(); _stillWatchingTimer?.cancel();
_unfocusStillWatchingPrompt(); _unfocusStillWatchingPrompt();
player?.pause(); final currentPlayer = player;
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
_setPlayerState(() { _setPlayerState(() {
_showStillWatchingPrompt = false; _showStillWatchingPrompt = false;
}); });
@@ -171,9 +171,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
if (event is PlayEvent) { if (event is PlayEvent) {
final currentPlayer = activePlayer!;
appLogger.d('Media control: Play event received'); appLogger.d('Media control: Play event received');
_seekBackForRewind(activePlayer!); unawaited(_seekBackForRewind(currentPlayer));
activePlayer.play(); unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false; _wasPlayingBeforeInactive = false;
_updateMediaControlsPlaybackState(); _updateMediaControlsPlaybackState();
} else if (event is PauseEvent) { } else if (event is PauseEvent) {
@@ -182,15 +183,16 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return; return;
} }
appLogger.d('Media control: Pause event received'); appLogger.d('Media control: Pause event received');
activePlayer!.pause(); unawaited(_pauseWithPlaybackIntent(activePlayer!));
_updateMediaControlsPlaybackState(); _updateMediaControlsPlaybackState();
} else if (event is TogglePlayPauseEvent) { } else if (event is TogglePlayPauseEvent) {
final currentPlayer = activePlayer!;
appLogger.d('Media control: Toggle play/pause event received'); appLogger.d('Media control: Toggle play/pause event received');
if (activePlayer!.state.isActive) { if (currentPlayer.state.isActive) {
activePlayer.pause(); unawaited(_pauseWithPlaybackIntent(currentPlayer));
} else { } else {
_seekBackForRewind(activePlayer); unawaited(_seekBackForRewind(currentPlayer));
activePlayer.play(); unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false; _wasPlayingBeforeInactive = false;
} }
_updateMediaControlsPlaybackState(); _updateMediaControlsPlaybackState();
@@ -266,7 +268,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
); );
final currentPlayer = player; final currentPlayer = player;
if (currentPlayer != null) { if (currentPlayer != null) {
unawaited(currentPlayer.pause()); unawaited(_pauseWithPlaybackIntent(currentPlayer));
} }
unawaited(_setWakelock(false)); unawaited(_setWakelock(false));
return; return;
@@ -339,7 +341,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
} }
try { try {
await currentPlayer.pause(); await _pauseWithPlaybackIntent(currentPlayer);
appLogger.d('Video paused after Apple audio session $reason'); appLogger.d('Video paused after Apple audio session $reason');
} catch (e) { } catch (e) {
appLogger.w('Failed to pause after Apple audio session $reason', error: 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; if (expectedPlayer.state.isActive) return;
try { try {
await expectedPlayer.play(); await _playWithPlaybackIntent(expectedPlayer);
_wasPlayingBeforeInactive = false; _wasPlayingBeforeInactive = false;
appLogger.d('Video resumed after Apple audio session $reason'); appLogger.d('Video resumed after Apple audio session $reason');
} catch (e) { } catch (e) {
+21 -21
View File
@@ -6,12 +6,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
if (!mounted || currentPlayer == null) return; if (!mounted || currentPlayer == null) return;
final target = clampSeekPosition(currentPlayer, position); final target = clampSeekPosition(currentPlayer, position);
if (!_shouldRestartPlexTranscodeForSeek) { if (_plexTranscodeSeekAction(currentPlayer, target) == PlexTranscodeSeekAction.nativeSeek) {
await currentPlayer.seek(target);
return;
}
if (_canSeekWithinCurrentTranscodeBuffer(currentPlayer, target)) {
await currentPlayer.seek(target); await currentPlayer.seek(target);
return; return;
} }
@@ -19,7 +14,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
await _restartPlexTranscodeAt(target); await _restartPlexTranscodeAt(target);
} }
bool get _shouldRestartPlexTranscodeForSeek { bool get _usesPlexVodTranscodeSeekPolicy {
return _isTranscoding && return _isTranscoding &&
!widget.isLive && !widget.isLive &&
!_isOfflinePlayback && !_isOfflinePlayback &&
@@ -27,15 +22,22 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
_selectedQualityPreset != TranscodeQualityPreset.original; _selectedQualityPreset != TranscodeQualityPreset.original;
} }
bool _canSeekWithinCurrentTranscodeBuffer(Player currentPlayer, Duration target) { PlexTranscodeSeekAction _plexTranscodeSeekAction(Player currentPlayer, Duration target) {
const edgeTolerance = Duration(milliseconds: 500); if (!_usesPlexVodTranscodeSeekPolicy) return PlexTranscodeSeekAction.nativeSeek;
final targetMs = target.inMilliseconds;
for (final range in currentPlayer.state.bufferRanges) { final state = currentPlayer.state;
final startMs = range.start.inMilliseconds - edgeTolerance.inMilliseconds; final action = resolvePlexTranscodeSeekAction(
final endMs = range.end.inMilliseconds - edgeTolerance.inMilliseconds; currentPosition: state.position,
if (targetMs >= startMs && targetMs <= endMs) return true; target: target,
} bufferRanges: state.bufferRanges,
return false; 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 { Future<void> _restartPlexTranscodeAt(Duration target) async {
@@ -52,8 +54,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
} }
final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds); final replacementMetadata = _currentMetadata.copyWith(viewOffsetMs: target.inMilliseconds);
final wasPlaying = currentPlayer.state.playing; final shouldResumePlayback = _playbackIntentShouldPlay;
final nextTranscodeSessionId = generateSessionIdentifier();
final offlineWatchService = context.read<OfflineWatchSyncService>(); final offlineWatchService = context.read<OfflineWatchSyncService>();
final playbackResolver = PlaybackSourceResolver( final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager, serverManager: context.read<MultiServerProvider>().serverManager,
@@ -61,7 +62,6 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
); );
try { try {
_playbackTranscodeSessionId = nextTranscodeSessionId;
final playbackContext = await playbackResolver.resolve( final playbackContext = await playbackResolver.resolve(
metadata: replacementMetadata, metadata: replacementMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex, selectedMediaIndex: _effectiveSelectedMediaIndex,
@@ -91,7 +91,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
player: currentPlayer, player: currentPlayer,
externalSubtitles: result.externalSubtitles, externalSubtitles: result.externalSubtitles,
); );
final shouldAutoPlay = wasPlaying && externalSubtitlePlan.canStartBeforeTrackSetup; final shouldAutoPlay = shouldResumePlayback && externalSubtitlePlan.canStartBeforeTrackSetup;
final didOpen = await _openMediaOnPlayer( final didOpen = await _openMediaOnPlayer(
player: currentPlayer, player: currentPlayer,
@@ -148,7 +148,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
externalSubtitlePlan: externalSubtitlePlan, externalSubtitlePlan: externalSubtitlePlan,
// A restart while paused must stay paused — selection is still // A restart while paused must stay paused — selection is still
// applied through the resume-skipped branch. // applied through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () => wasPlaying && mounted && player == currentPlayer, shouldResumeAfterSubtitleLoad: () => shouldResumePlayback && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: true, 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 // entry guards make reload / transcode-restart / channel-switch mutually
// exclusive instead of relying on three independent booleans. // exclusive instead of relying on three independent booleans.
_PlaybackTransition _playbackTransition = _PlaybackTransition.idle; _PlaybackTransition _playbackTransition = _PlaybackTransition.idle;
bool _playbackIntentShouldPlay = true;
bool _showPlayNextDialog = false; bool _showPlayNextDialog = false;
bool _isPhone = false; bool _isPhone = false;
@@ -484,6 +485,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return mounted && player == currentPlayer && _playbackGeneration == generation; 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> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isExiting = 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 // 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 // Enable wakelock to prevent screen from turning off during playback
unawaited(_setWakelock(true)); unawaited(_setWakelock(true));
@@ -1065,7 +1081,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final exitPosition = currentPlayer.state.position; final exitPosition = currentPlayer.state.position;
if (currentPlayer.state.isActive) { if (currentPlayer.state.isActive) {
try { try {
await currentPlayer.pause(); await _pauseWithPlaybackIntent(currentPlayer);
} catch (e, st) { } catch (e, st) {
appLogger.w('Failed to pause player during route exit', error: e, stackTrace: 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); await _seekBackForRewind(currentPlayer);
if (!mounted || player != currentPlayer) return; if (!mounted || player != currentPlayer) return;
} }
await currentPlayer.playOrPause(); await _playOrPauseWithPlaybackIntent(currentPlayer);
} catch (e, st) { } catch (e, st) {
appLogger.w('Apple TV remote play/pause failed', error: e, stackTrace: st); appLogger.w('Apple TV remote play/pause failed', error: e, stackTrace: st);
} }
+42
View File
@@ -1,6 +1,11 @@
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
const restartBeforePreviousItemThreshold = Duration(seconds: 3); 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) { bool shouldRestartBeforePreviousItem(Duration position) {
return position > restartBeforePreviousItemThreshold; return position > restartBeforePreviousItemThreshold;
@@ -12,3 +17,40 @@ Duration clampSeekPosition(Player player, Duration position) {
if (duration > Duration.zero && position > duration) return duration; if (duration > Duration.zero && position > duration) return duration;
return position; 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 // Preview thumbnail during sustained dpad/keyboard seeking
bool _showKeyRepeatThumbnail = false; bool _showKeyRepeatThumbnail = false;
Timer? _keyRepeatThumbnailTimer; Timer? _keyRepeatThumbnailTimer;
Timer? _timelineSeekDebounceTimer;
Timer? _timelinePreviewClearTimer;
Duration? _timelinePreviewPosition;
Duration? _lastFlushedTimelinePreviewPosition;
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
static const _timelineSeekDebounce = Duration(milliseconds: 350);
static const _timelinePreviewClearDelay = Duration(seconds: 2);
// Content strip state // Content strip state
bool _contentStripVisible = false; bool _contentStripVisible = false;
@@ -236,6 +242,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
@override @override
void dispose() { void dispose() {
_keyRepeatThumbnailTimer?.cancel(); _keyRepeatThumbnailTimer?.cancel();
_timelineSeekDebounceTimer?.cancel();
_timelinePreviewClearTimer?.cancel();
_prevItemFocusNode.dispose(); _prevItemFocusNode.dispose();
_prevChapterFocusNode.dispose(); _prevChapterFocusNode.dispose();
_skipBackFocusNode.dispose(); _skipBackFocusNode.dispose();
@@ -307,6 +315,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
} else { } else {
// Reset progressive seek state when timeline loses focus // Reset progressive seek state when timeline loses focus
_flushTimelinePreviewSeek();
_resetSeekState(); _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. /// Show the timeline preview thumbnail during sustained key-repeat seeking.
/// Arms a short timer that hides the thumbnail once repeats stop. /// Arms a short timer that hides the thumbnail once repeats stop.
void _triggerKeyRepeatThumbnail() { void _triggerKeyRepeatThumbnail() {
@@ -493,6 +536,11 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Handle key release to reset progressive seek state // Handle key release to reset progressive seek state
if (event is KeyUpEvent) { if (event is KeyUpEvent) {
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
if (_trackControlsState.isTranscoding) {
_flushTimelinePreviewSeek();
_resetSeekState();
return KeyEventResult.handled;
}
_resetSeekState(); _resetSeekState();
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
@@ -507,6 +555,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// UP arrow - hide controls and reset seek state // UP arrow - hide controls and reset seek state
if (key == LogicalKeyboardKey.arrowUp) { if (key == LogicalKeyboardKey.arrowUp) {
_flushTimelinePreviewSeek();
_resetSeekState(); _resetSeekState();
widget.onHideControls?.call(); widget.onHideControls?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -514,6 +563,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// DOWN arrow - move focus to play/pause button and reset seek state // DOWN arrow - move focus to play/pause button and reset seek state
if (key == LogicalKeyboardKey.arrowDown) { if (key == LogicalKeyboardKey.arrowDown) {
_flushTimelinePreviewSeek();
_resetSeekState(); _resetSeekState();
_playPauseFocusNode.requestFocus(); _playPauseFocusNode.requestFocus();
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
@@ -560,6 +610,16 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Clamp to valid range // Clamp to valid range
final clampedPosition = Duration(milliseconds: newPosition.inMilliseconds.clamp(0, duration.inMilliseconds)); 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.onSeek(clampedPosition);
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -730,6 +790,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
enabled: canInteract, enabled: canInteract,
thumbnailDataBuilder: widget.thumbnailDataBuilder, thumbnailDataBuilder: widget.thumbnailDataBuilder,
showKeyRepeatThumbnail: _showKeyRepeatThumbnail, showKeyRepeatThumbnail: _showKeyRepeatThumbnail,
previewPosition: _timelinePreviewPosition,
), ),
], ],
// Row 2: Playback controls and options // Row 2: Playback controls and options
@@ -84,7 +84,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
final clamped = clampSeekPosition(widget.player, target); final clamped = clampSeekPosition(widget.player, target);
await (widget.onSeekRequested ?? widget.player.seek)(clamped); 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 /// 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. /// the server-side transcode session at the requested absolute timestamp.
final Future<void> Function(Duration position)? onSeekRequested; 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) /// Called when a seek operation completes (for Watch Together sync)
final Function(Duration position)? onSeekCompleted; final Function(Duration position)? onSeekCompleted;
@@ -307,6 +311,7 @@ class PlexVideoControls extends StatefulWidget {
this.onSubtitleTrackChanged, this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged, this.onSecondarySubtitleTrackChanged,
this.onSeekRequested, this.onSeekRequested,
this.onPlayPauseRequested,
this.onSeekCompleted, this.onSeekCompleted,
this.onBack, this.onBack,
this.onReachedEnd, this.onReachedEnd,
@@ -47,6 +47,9 @@ class VideoTimelineBar extends StatelessWidget {
/// (used during sustained dpad/keyboard key-repeat seeking). /// (used during sustained dpad/keyboard key-repeat seeking).
final bool showKeyRepeatThumbnail; final bool showKeyRepeatThumbnail;
/// Optional UI-only position used while a remote/keyboard seek is pending.
final Duration? previewPosition;
const VideoTimelineBar({ const VideoTimelineBar({
super.key, super.key,
required this.player, required this.player,
@@ -65,6 +68,7 @@ class VideoTimelineBar extends StatelessWidget {
this.showFinishTime = false, this.showFinishTime = false,
this.thumbnailDataBuilder, this.thumbnailDataBuilder,
this.showKeyRepeatThumbnail = false, this.showKeyRepeatThumbnail = false,
this.previewPosition,
}); });
@override @override
@@ -81,8 +85,9 @@ class VideoTimelineBar extends StatelessWidget {
stream: player.streams.bufferRanges, stream: player.streams.bufferRanges,
initialData: player.state.bufferRanges, initialData: player.state.bufferRanges,
builder: (context, bufferRangesSnapshot) { builder: (context, bufferRangesSnapshot) {
final position = positionSnapshot.data ?? Duration.zero; final rawPosition = positionSnapshot.data ?? Duration.zero;
final duration = durationSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero;
final position = previewPosition == null ? rawPosition : clampDuration(previewPosition!, duration);
final bufferRanges = bufferRangesSnapshot.data ?? const []; final bufferRanges = bufferRangesSnapshot.data ?? const [];
final remaining = position - duration; // We want this to be negative 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( Widget _buildHorizontalLayout(
Duration position, Duration position,
Duration duration, Duration duration,
+78
View File
@@ -165,6 +165,7 @@ void main() {
final openCall = calls.singleWhere((call) => call.method == 'open'); final openCall = calls.singleWhere((call) => call.method == 'open');
final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map); final openArgs = Map<Object?, Object?>.from(openCall.arguments as Map);
expect(openArgs['startPositionMs'], 0); expect(openArgs['startPositionMs'], 0);
expect(openArgs['hasStartPosition'], isFalse);
await Future<void>.delayed(const Duration(milliseconds: 260)); await Future<void>.delayed(const Duration(milliseconds: 260));
player.handlePropertyChange('time-pos', 2058.0); 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 { test('MPV clears stale Dart track state before opening new media', () async {
await _withMockChannels( await _withMockChannels(
methodChannelName: 'com.plezy/mpv_player', methodChannelName: 'com.plezy/mpv_player',
+142 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:plezy/utils/player_utils.dart';
void main() { void main() {
@@ -33,6 +33,147 @@ void main() {
expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 6)); 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 { 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/skip_marker_button.dart';
import 'package:plezy/widgets/video_controls/widgets/sync_offset_control.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/timeline_slider.dart';
import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart';
import '../test_helpers/watch_together_fakes.dart';
const _testTokens = MonoTokens( const _testTokens = MonoTokens(
radiusSm: 8, radiusSm: 8,
@@ -419,6 +422,32 @@ void main() {
expect(slider.max, 0.0); 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( Future<void> pumpScrubSlider(
WidgetTester tester, { WidgetTester tester, {
required List<Duration> seeks, required List<Duration> seeks,