From 2a7db67ab7b13dc0c63b23005c04e05c5f1d8bd3 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 21:34:39 +0300 Subject: [PATCH 1/4] feat: add seekStart/seekFinished API, restart(), and onRestart callback - Fix seek at beginning of slider drag (#134): remove implicit seekTo from sliderPos setter on Android and Windows - Add seekStart()/seekFinished() for clean slider interactions - Fix play() from ended state on Android (STATE_ENDED) and iOS (#82) - Add restart() method to reliably restart from any state - Add onRestart callback fired when loop=true and video restarts - Handle loop manually on web instead of native HTML5 loop to support onRestart detection - Update README with new seek API and playback callbacks documentation --- README.MD | 52 ++++++++++++++----- .../VideoPlayerState.android.kt | 30 +++++++++-- .../composemediaplayer/VideoPlayerState.kt | 35 +++++++++++++ .../VideoPlayerState.ios.kt | 46 +++++++++++++++- .../VideoPlayerState.jvm.kt | 8 +++ .../linux/LinuxVideoPlayerState.kt | 2 + .../mac/MacVideoPlayerState.kt | 2 + .../windows/WindowsVideoPlayerState.kt | 3 +- .../VideoPlayerState.web.kt | 1 + .../VideoPlayerSurfaceImpl.kt | 17 +++--- .../kotlin/sample/app/player/PlayerScreen.kt | 10 +--- 11 files changed, 172 insertions(+), 34 deletions(-) diff --git a/README.MD b/README.MD index cc66182d..8ceefc84 100644 --- a/README.MD +++ b/README.MD @@ -290,7 +290,33 @@ println("Volume set to 50%") ```kotlin playerState.loop = true // Enable loop playback -println("Loop playback enabled") +``` + +You can listen for loop restarts via the `onRestart` callback: + +```kotlin +playerState.loop = true +playerState.onRestart = { + println("Video restarted from the beginning") +} +``` + +- **Restart**: + +Restart playback from the beginning. Works reliably from any state, including when the video has ended: + +```kotlin +playerState.restart() +``` + +- **Playback End Callback**: + +Get notified when playback reaches the end (only called when `loop` is `false`): + +```kotlin +playerState.onPlaybackEnded = { + println("Video finished") +} ``` - **Playback Speed**: @@ -304,25 +330,27 @@ You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The d ### Progress Indicators -To display and control playback progress: +To display and control playback progress, use `seekStart` and `seekFinished` for slider interactions: ```kotlin Slider( value = playerState.sliderPos, - onValueChange = { - playerState.sliderPos = it - playerState.userDragging = true - println("Position changed: $it") - }, - onValueChangeFinished = { - playerState.userDragging = false - playerState.seekTo(playerState.sliderPos) - println("Position finalized: ${playerState.sliderPos}") - }, + onValueChange = { playerState.seekStart(it) }, + onValueChangeFinished = { playerState.seekFinished() }, valueRange = 0f..1000f ) ``` +- `seekStart(value)` updates the slider position visually without performing the actual seek, allowing smooth dragging. +- `seekFinished()` commits the seek to the player and ends the drag interaction. + +For programmatic seeking (e.g. skip forward/backward), use `seekTo` directly: + +```kotlin +// Seek to the middle of the video +playerState.seekTo(500f) +``` + ### Display Left and Right Volume Levels To display audio levels: diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index b19ed575..8365aa88 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -218,15 +218,13 @@ open class DefaultVideoPlayerState( get() = _sliderPos set(value) { _sliderPos = value.coerceIn(0f, 1000f) - if (!userDragging) { - seekTo(value) - } } // User interaction states override var userDragging by mutableStateOf(false) override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null // Loop control private var _loop by mutableStateOf(false) @@ -478,6 +476,16 @@ open class DefaultVideoPlayerState( } } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION && _loop) { + onRestart?.invoke() + } + } + override fun onVideoSizeChanged(videoSize: VideoSize) { if (videoSize.width > 0 && videoSize.height > 0) { _aspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat() @@ -673,6 +681,8 @@ open class DefaultVideoPlayerState( exoPlayer?.let { player -> if (player.playbackState == Player.STATE_IDLE) { player.prepare() + } else if (player.playbackState == Player.STATE_ENDED) { + player.seekTo(0) } player.play() } @@ -681,6 +691,20 @@ open class DefaultVideoPlayerState( } } + override fun restart() { + synchronized(playerInitializationLock) { + if (!isPlayerReleased) { + exoPlayer?.let { player -> + if (player.playbackState == Player.STATE_IDLE) { + player.prepare() + } + player.seekTo(0) + player.play() + } + } + } + } + override fun pause() { synchronized(playerInitializationLock) { if (!isPlayerReleased) { diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt index 6be9a71a..f5fdd5a0 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -58,6 +58,12 @@ interface VideoPlayerState { */ var onPlaybackEnded: (() -> Unit)? + /** + * Callback invoked when playback restarts from the beginning due to looping. + * Only called when [loop] is true. May be invoked from a background thread. + */ + var onRestart: (() -> Unit)? + companion object { const val MIN_PLAYBACK_SPEED = 0.25f const val MAX_PLAYBACK_SPEED = 2.0f @@ -111,6 +117,34 @@ interface VideoPlayerState { */ fun seekTo(value: Float) + /** + * Begins a user-driven seek interaction (e.g. slider drag). + * Updates the visual slider position without performing the actual seek on the player. + * Must be followed by [seekFinished] to commit the seek. + */ + fun seekStart(value: Float) { + userDragging = true + sliderPos = value + } + + /** + * Commits the seek after a user-driven seek interaction. + * Performs the actual seek on the player and ends the dragging state. + */ + fun seekFinished() { + seekTo(sliderPos) + userDragging = false + } + + /** + * Restarts playback from the beginning. Works reliably from any state, + * including when playback has ended. + */ + fun restart() { + seekTo(0f) + play() + } + fun toggleFullscreen() // Functions to manage media sources @@ -236,6 +270,7 @@ data class PreviewableVideoPlayerState( override var isPipActive: Boolean = false, override var isPipEnabled: Boolean = false, override var onPlaybackEnded: (() -> Unit)? = null, + override var onRestart: (() -> Unit)? = null, ) : VideoPlayerState { override fun play() {} diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index ace91049..e31e7b2a 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -73,6 +73,7 @@ open class DefaultVideoPlayerState( } override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null override var sliderPos: Float by mutableStateOf(0f) // value between 0 and 1000 override var userDragging: Boolean = false private var _loop by mutableStateOf(false) @@ -340,6 +341,7 @@ open class DefaultVideoPlayerState( if (finished) { dispatch_async(dispatch_get_main_queue()) { player.playImmediatelyAtRate(_playbackSpeed) + onRestart?.invoke() } } } @@ -605,16 +607,56 @@ open class DefaultVideoPlayerState( override fun play() { iosLogger.d { "play called" } - if (player == null) { + val currentPlayer = player + if (currentPlayer == null) { iosLogger.d { "play: player is null" } return } // Configure audio session configureAudioSession() - player?.playImmediatelyAtRate(_playbackSpeed) + // If the player has reached the end, seek to the beginning first + val currentItem = currentPlayer.currentItem + if (currentItem != null) { + val currentTime = CMTimeGetSeconds(currentItem.currentTime()) + val duration = CMTimeGetSeconds(currentItem.duration) + if (duration > 0 && currentTime >= duration) { + val zeroTime = CMTimeMake(0, 1) + currentPlayer.seekToTime( + time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()), + toleranceBefore = zeroTime, + toleranceAfter = zeroTime, + ) { finished -> + if (finished) { + dispatch_async(dispatch_get_main_queue()) { + currentPlayer.playImmediatelyAtRate(_playbackSpeed) + } + } + } + return + } + } + currentPlayer.playImmediatelyAtRate(_playbackSpeed) // KVO will update isPlaying } + override fun restart() { + iosLogger.d { "restart called" } + val currentPlayer = player ?: return + configureAudioSession() + val zeroTime = CMTimeMake(0, 1) + currentPlayer.seekToTime( + time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()), + toleranceBefore = zeroTime, + toleranceAfter = zeroTime, + ) { finished -> + if (finished) { + dispatch_async(dispatch_get_main_queue()) { + currentPlayer.playImmediatelyAtRate(_playbackSpeed) + } + } + } + } + override fun pause() { iosLogger.d { "pause called" } // Ensure the pause call is on the main thread: diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index 671a5374..89813226 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -133,5 +133,13 @@ open class DefaultVideoPlayerState : VideoPlayerState { delegate.onPlaybackEnded = value } + override var onRestart: (() -> Unit)? + get() = delegate.onRestart + set(value) { + delegate.onRestart = value + } + + override fun restart() = delegate.restart() + override fun clearError() = delegate.clearError() } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt index dba4c869..bb7d8ec5 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt @@ -83,6 +83,7 @@ class LinuxVideoPlayerState : VideoPlayerState { override var loop: Boolean by mutableStateOf(false) override var isLoading: Boolean by mutableStateOf(false) override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null override var error: VideoPlayerError? by mutableStateOf(null) override var subtitlesEnabled: Boolean by mutableStateOf(false) override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null) @@ -576,6 +577,7 @@ class LinuxVideoPlayerState : VideoPlayerState { if (loop) { seekToAsync(0f) + onRestart?.invoke() } else { withContext(Dispatchers.Main) { isPlaying = false } pauseInBackground() diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt index 1433215f..b3547324 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt @@ -80,6 +80,7 @@ class MacVideoPlayerState : VideoPlayerState { override var loop: Boolean by mutableStateOf(false) override var isLoading: Boolean by mutableStateOf(false) override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null override var error: VideoPlayerError? by mutableStateOf(null) override var subtitlesEnabled: Boolean by mutableStateOf(false) override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null) @@ -700,6 +701,7 @@ class MacVideoPlayerState : VideoPlayerState { if (loop) { macLogger.d { "checkLoopingAsync() - Loop enabled, restarting video" } seekToAsync(0f) + onRestart?.invoke() } else { macLogger.d { "checkLoopingAsync() - Video completed, updating state" } withContext(Dispatchers.Main) { diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt index b5b025d6..571c975a 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt @@ -145,7 +145,6 @@ class WindowsVideoPlayerState : VideoPlayerState { get() = _progress * 1000f set(value) { _progress = (value / 1000f).coerceIn(0f, 1f) - if (!userDragging) seekTo(value) } private var _userDragging by mutableStateOf(false) override var userDragging: Boolean @@ -161,6 +160,7 @@ class WindowsVideoPlayerState : VideoPlayerState { } override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null private var _playbackSpeed by mutableStateOf(1.0f) override var playbackSpeed: Float @@ -665,6 +665,7 @@ class WindowsVideoPlayerState : VideoPlayerState { lastFrameHash = Int.MIN_VALUE // Reset hash for new loop seekTo(0f) play() + onRestart?.invoke() } catch (e: Exception) { setError("Error during SeekMedia for loop: ${e.message}") } diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index a9dfc99e..ce31d10f 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -63,6 +63,7 @@ open class DefaultVideoPlayerState : VideoPlayerState { // Error handling override var onPlaybackEnded: (() -> Unit)? = null + override var onRestart: (() -> Unit)? = null private var _error by mutableStateOf(null) override val error: VideoPlayerError? get() = _error diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt index e70ce866..66185d58 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -380,8 +380,14 @@ internal fun setupVideoElement( "timeupdate" to { event -> playerState.onTimeUpdateEvent(event) }, "ended" to { scope.launch { - playerState.pause() - playerState.onPlaybackEnded?.invoke() + if (playerState.loop) { + playerState.seekTo(0f) + playerState.play() + playerState.onRestart?.invoke() + } else { + playerState.pause() + playerState.onPlaybackEnded?.invoke() + } } }, ), @@ -579,12 +585,7 @@ internal fun VideoPlayerEffects( } } - // Handle loop property - LaunchedEffect(playerState.loop) { - videoElement?.let { video -> - video.loop = playerState.loop - } - } + // Loop is handled manually via the "ended" event to support the onRestart callback // Store state before video element recreation LaunchedEffect(useCors) { diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt index b189461d..e94801cd 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt @@ -349,14 +349,8 @@ private fun ControlsOverlay( // Seek bar Slider( value = playerState.sliderPos, - onValueChange = { - playerState.sliderPos = it - playerState.userDragging = true - }, - onValueChangeFinished = { - playerState.userDragging = false - playerState.seekTo(playerState.sliderPos) - }, + onValueChange = { playerState.seekStart(it) }, + onValueChangeFinished = { playerState.seekFinished() }, valueRange = 0f..1000f, colors = SliderDefaults.colors( thumbColor = Color.White, From ab51ab9567eeb2d470eb3aab3eaf324947150039 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 21:38:02 +0300 Subject: [PATCH 2/4] fix: use direct video element control for web loop restart --- .../composemediaplayer/VideoPlayerSurfaceImpl.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt index 66185d58..aebdd251 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -381,8 +381,9 @@ internal fun setupVideoElement( "ended" to { scope.launch { if (playerState.loop) { - playerState.seekTo(0f) - playerState.play() + video.safeSetCurrentTime(0.0) + video.safePlay() + playerState.sliderPos = 0f playerState.onRestart?.invoke() } else { playerState.pause() From 62f26604520c73389f457baaeae7e3c6ed5152e2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 21:42:07 +0300 Subject: [PATCH 3/4] fix: web progress bar now reflects actual playback time Remove aggressive near-end snapping (10% threshold) that caused the slider to jump to 100% too early. Reduce update throttle from 1s to 250ms for smoother progress tracking. --- .../VideoPlayerState.web.kt | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index ce31d10f..0ed52315 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -362,38 +362,14 @@ open class DefaultVideoPlayerState : VideoPlayerState { forceUpdate: Boolean = false, ) { val now = TimeSource.Monotonic.markNow() - if (forceUpdate || now - lastUpdateTime >= 1.seconds) { - // Calculate a dynamic threshold based on video duration (10% of duration or at least 0.5 seconds) - val threshold = - if (duration > 0f && !duration.isNaN()) { - maxOf(duration * 0.1f, 0.5f) - } else { - 0.5f - } - - // Check if we're very close to the end of the video - val isNearEnd = - duration > 0f && - !duration.isNaN() && - !currentTime.isNaN() && - (duration - currentTime < threshold) - - // If we're near the end, use the duration as the current time - val displayTime = if (isNearEnd) duration else currentTime - - _positionText = if (displayTime.isNaN()) "00:00" else formatTime(displayTime) + if (forceUpdate || now - lastUpdateTime >= 250.milliseconds) { + _positionText = if (currentTime.isNaN()) "00:00" else formatTime(currentTime) _durationText = if (duration.isNaN()) "00:00" else formatTime(duration) - // Update the current time property - _currentTime = displayTime.toDouble() + _currentTime = currentTime.toDouble() if (!userDragging && duration > 0f && !duration.isNaN() && !_isLoading) { - sliderPos = - if (isNearEnd) { - PERCENTAGE_MULTIPLIER // Set to 100% if near end - } else { - (currentTime / duration) * PERCENTAGE_MULTIPLIER - } + sliderPos = (currentTime / duration) * PERCENTAGE_MULTIPLIER } _currentDuration = duration lastUpdateTime = now From 931ca6e1f5724825115d460839a3580b13039e13 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 21:44:22 +0300 Subject: [PATCH 4/4] fix: web slider drag seek now works Add userDragging as LaunchedEffect key so the seek triggers when the drag ends, not only when sliderPos changes. --- .../kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt index aebdd251..ee0b44f1 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -617,8 +617,8 @@ internal fun VideoPlayerEffects( } } - // Handle seeking - LaunchedEffect(playerState.sliderPos) { + // Handle seeking — react to both sliderPos changes and drag end (userDragging → false) + LaunchedEffect(playerState.sliderPos, playerState.userDragging) { if (playerState is DefaultVideoPlayerState && !playerState.userDragging && playerState.hasMedia) { playerState.seekJob?.cancel()