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