@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user