Skip to content

Commit 8d0c619

Browse files
authored
Merge pull request #195 from kdroidFilter/feat/seek-api-restart-and-loop-callback
feat: add seekStart/seekFinished API, restart(), and onRestart callback
2 parents 1f7f696 + 931ca6e commit 8d0c619

11 files changed

Lines changed: 179 additions & 64 deletions

File tree

README.MD

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,33 @@ println("Volume set to 50%")
290290

291291
```kotlin
292292
playerState.loop = true // Enable loop playback
293-
println("Loop playback enabled")
293+
```
294+
295+
You can listen for loop restarts via the `onRestart` callback:
296+
297+
```kotlin
298+
playerState.loop = true
299+
playerState.onRestart = {
300+
println("Video restarted from the beginning")
301+
}
302+
```
303+
304+
- **Restart**:
305+
306+
Restart playback from the beginning. Works reliably from any state, including when the video has ended:
307+
308+
```kotlin
309+
playerState.restart()
310+
```
311+
312+
- **Playback End Callback**:
313+
314+
Get notified when playback reaches the end (only called when `loop` is `false`):
315+
316+
```kotlin
317+
playerState.onPlaybackEnded = {
318+
println("Video finished")
319+
}
294320
```
295321

296322
- **Playback Speed**:
@@ -304,25 +330,27 @@ You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The d
304330

305331
### Progress Indicators
306332

307-
To display and control playback progress:
333+
To display and control playback progress, use `seekStart` and `seekFinished` for slider interactions:
308334

309335
```kotlin
310336
Slider(
311337
value = playerState.sliderPos,
312-
onValueChange = {
313-
playerState.sliderPos = it
314-
playerState.userDragging = true
315-
println("Position changed: $it")
316-
},
317-
onValueChangeFinished = {
318-
playerState.userDragging = false
319-
playerState.seekTo(playerState.sliderPos)
320-
println("Position finalized: ${playerState.sliderPos}")
321-
},
338+
onValueChange = { playerState.seekStart(it) },
339+
onValueChangeFinished = { playerState.seekFinished() },
322340
valueRange = 0f..1000f
323341
)
324342
```
325343

344+
- `seekStart(value)` updates the slider position visually without performing the actual seek, allowing smooth dragging.
345+
- `seekFinished()` commits the seek to the player and ends the drag interaction.
346+
347+
For programmatic seeking (e.g. skip forward/backward), use `seekTo` directly:
348+
349+
```kotlin
350+
// Seek to the middle of the video
351+
playerState.seekTo(500f)
352+
```
353+
326354
### Display Left and Right Volume Levels
327355

328356
To display audio levels:

mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,15 +218,13 @@ open class DefaultVideoPlayerState(
218218
get() = _sliderPos
219219
set(value) {
220220
_sliderPos = value.coerceIn(0f, 1000f)
221-
if (!userDragging) {
222-
seekTo(value)
223-
}
224221
}
225222

226223
// User interaction states
227224
override var userDragging by mutableStateOf(false)
228225

229226
override var onPlaybackEnded: (() -> Unit)? = null
227+
override var onRestart: (() -> Unit)? = null
230228

231229
// Loop control
232230
private var _loop by mutableStateOf(false)
@@ -478,6 +476,16 @@ open class DefaultVideoPlayerState(
478476
}
479477
}
480478

479+
override fun onPositionDiscontinuity(
480+
oldPosition: Player.PositionInfo,
481+
newPosition: Player.PositionInfo,
482+
reason: Int,
483+
) {
484+
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION && _loop) {
485+
onRestart?.invoke()
486+
}
487+
}
488+
481489
override fun onVideoSizeChanged(videoSize: VideoSize) {
482490
if (videoSize.width > 0 && videoSize.height > 0) {
483491
_aspectRatio = videoSize.width.toFloat() / videoSize.height.toFloat()
@@ -673,6 +681,8 @@ open class DefaultVideoPlayerState(
673681
exoPlayer?.let { player ->
674682
if (player.playbackState == Player.STATE_IDLE) {
675683
player.prepare()
684+
} else if (player.playbackState == Player.STATE_ENDED) {
685+
player.seekTo(0)
676686
}
677687
player.play()
678688
}
@@ -681,6 +691,20 @@ open class DefaultVideoPlayerState(
681691
}
682692
}
683693

694+
override fun restart() {
695+
synchronized(playerInitializationLock) {
696+
if (!isPlayerReleased) {
697+
exoPlayer?.let { player ->
698+
if (player.playbackState == Player.STATE_IDLE) {
699+
player.prepare()
700+
}
701+
player.seekTo(0)
702+
player.play()
703+
}
704+
}
705+
}
706+
}
707+
684708
override fun pause() {
685709
synchronized(playerInitializationLock) {
686710
if (!isPlayerReleased) {

mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ interface VideoPlayerState {
5858
*/
5959
var onPlaybackEnded: (() -> Unit)?
6060

61+
/**
62+
* Callback invoked when playback restarts from the beginning due to looping.
63+
* Only called when [loop] is true. May be invoked from a background thread.
64+
*/
65+
var onRestart: (() -> Unit)?
66+
6167
companion object {
6268
const val MIN_PLAYBACK_SPEED = 0.25f
6369
const val MAX_PLAYBACK_SPEED = 2.0f
@@ -111,6 +117,34 @@ interface VideoPlayerState {
111117
*/
112118
fun seekTo(value: Float)
113119

120+
/**
121+
* Begins a user-driven seek interaction (e.g. slider drag).
122+
* Updates the visual slider position without performing the actual seek on the player.
123+
* Must be followed by [seekFinished] to commit the seek.
124+
*/
125+
fun seekStart(value: Float) {
126+
userDragging = true
127+
sliderPos = value
128+
}
129+
130+
/**
131+
* Commits the seek after a user-driven seek interaction.
132+
* Performs the actual seek on the player and ends the dragging state.
133+
*/
134+
fun seekFinished() {
135+
seekTo(sliderPos)
136+
userDragging = false
137+
}
138+
139+
/**
140+
* Restarts playback from the beginning. Works reliably from any state,
141+
* including when playback has ended.
142+
*/
143+
fun restart() {
144+
seekTo(0f)
145+
play()
146+
}
147+
114148
fun toggleFullscreen()
115149

116150
// Functions to manage media sources
@@ -236,6 +270,7 @@ data class PreviewableVideoPlayerState(
236270
override var isPipActive: Boolean = false,
237271
override var isPipEnabled: Boolean = false,
238272
override var onPlaybackEnded: (() -> Unit)? = null,
273+
override var onRestart: (() -> Unit)? = null,
239274
) : VideoPlayerState {
240275
override fun play() {}
241276

mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ open class DefaultVideoPlayerState(
7373
}
7474

7575
override var onPlaybackEnded: (() -> Unit)? = null
76+
override var onRestart: (() -> Unit)? = null
7677
override var sliderPos: Float by mutableStateOf(0f) // value between 0 and 1000
7778
override var userDragging: Boolean = false
7879
private var _loop by mutableStateOf(false)
@@ -340,6 +341,7 @@ open class DefaultVideoPlayerState(
340341
if (finished) {
341342
dispatch_async(dispatch_get_main_queue()) {
342343
player.playImmediatelyAtRate(_playbackSpeed)
344+
onRestart?.invoke()
343345
}
344346
}
345347
}
@@ -605,16 +607,56 @@ open class DefaultVideoPlayerState(
605607

606608
override fun play() {
607609
iosLogger.d { "play called" }
608-
if (player == null) {
610+
val currentPlayer = player
611+
if (currentPlayer == null) {
609612
iosLogger.d { "play: player is null" }
610613
return
611614
}
612615
// Configure audio session
613616
configureAudioSession()
614-
player?.playImmediatelyAtRate(_playbackSpeed)
617+
// If the player has reached the end, seek to the beginning first
618+
val currentItem = currentPlayer.currentItem
619+
if (currentItem != null) {
620+
val currentTime = CMTimeGetSeconds(currentItem.currentTime())
621+
val duration = CMTimeGetSeconds(currentItem.duration)
622+
if (duration > 0 && currentTime >= duration) {
623+
val zeroTime = CMTimeMake(0, 1)
624+
currentPlayer.seekToTime(
625+
time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()),
626+
toleranceBefore = zeroTime,
627+
toleranceAfter = zeroTime,
628+
) { finished ->
629+
if (finished) {
630+
dispatch_async(dispatch_get_main_queue()) {
631+
currentPlayer.playImmediatelyAtRate(_playbackSpeed)
632+
}
633+
}
634+
}
635+
return
636+
}
637+
}
638+
currentPlayer.playImmediatelyAtRate(_playbackSpeed)
615639
// KVO will update isPlaying
616640
}
617641

642+
override fun restart() {
643+
iosLogger.d { "restart called" }
644+
val currentPlayer = player ?: return
645+
configureAudioSession()
646+
val zeroTime = CMTimeMake(0, 1)
647+
currentPlayer.seekToTime(
648+
time = CMTimeMakeWithSeconds(0.0, NSEC_PER_SEC.toInt()),
649+
toleranceBefore = zeroTime,
650+
toleranceAfter = zeroTime,
651+
) { finished ->
652+
if (finished) {
653+
dispatch_async(dispatch_get_main_queue()) {
654+
currentPlayer.playImmediatelyAtRate(_playbackSpeed)
655+
}
656+
}
657+
}
658+
}
659+
618660
override fun pause() {
619661
iosLogger.d { "pause called" }
620662
// Ensure the pause call is on the main thread:

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,13 @@ open class DefaultVideoPlayerState : VideoPlayerState {
133133
delegate.onPlaybackEnded = value
134134
}
135135

136+
override var onRestart: (() -> Unit)?
137+
get() = delegate.onRestart
138+
set(value) {
139+
delegate.onRestart = value
140+
}
141+
142+
override fun restart() = delegate.restart()
143+
136144
override fun clearError() = delegate.clearError()
137145
}

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
8383
override var loop: Boolean by mutableStateOf(false)
8484
override var isLoading: Boolean by mutableStateOf(false)
8585
override var onPlaybackEnded: (() -> Unit)? = null
86+
override var onRestart: (() -> Unit)? = null
8687
override var error: VideoPlayerError? by mutableStateOf(null)
8788
override var subtitlesEnabled: Boolean by mutableStateOf(false)
8889
override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null)
@@ -576,6 +577,7 @@ class LinuxVideoPlayerState : VideoPlayerState {
576577

577578
if (loop) {
578579
seekToAsync(0f)
580+
onRestart?.invoke()
579581
} else {
580582
withContext(Dispatchers.Main) { isPlaying = false }
581583
pauseInBackground()

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class MacVideoPlayerState : VideoPlayerState {
8080
override var loop: Boolean by mutableStateOf(false)
8181
override var isLoading: Boolean by mutableStateOf(false)
8282
override var onPlaybackEnded: (() -> Unit)? = null
83+
override var onRestart: (() -> Unit)? = null
8384
override var error: VideoPlayerError? by mutableStateOf(null)
8485
override var subtitlesEnabled: Boolean by mutableStateOf(false)
8586
override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null)
@@ -700,6 +701,7 @@ class MacVideoPlayerState : VideoPlayerState {
700701
if (loop) {
701702
macLogger.d { "checkLoopingAsync() - Loop enabled, restarting video" }
702703
seekToAsync(0f)
704+
onRestart?.invoke()
703705
} else {
704706
macLogger.d { "checkLoopingAsync() - Video completed, updating state" }
705707
withContext(Dispatchers.Main) {

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ class WindowsVideoPlayerState : VideoPlayerState {
145145
get() = _progress * 1000f
146146
set(value) {
147147
_progress = (value / 1000f).coerceIn(0f, 1f)
148-
if (!userDragging) seekTo(value)
149148
}
150149
private var _userDragging by mutableStateOf(false)
151150
override var userDragging: Boolean
@@ -161,6 +160,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
161160
}
162161

163162
override var onPlaybackEnded: (() -> Unit)? = null
163+
override var onRestart: (() -> Unit)? = null
164164

165165
private var _playbackSpeed by mutableStateOf(1.0f)
166166
override var playbackSpeed: Float
@@ -665,6 +665,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
665665
lastFrameHash = Int.MIN_VALUE // Reset hash for new loop
666666
seekTo(0f)
667667
play()
668+
onRestart?.invoke()
668669
} catch (e: Exception) {
669670
setError("Error during SeekMedia for loop: ${e.message}")
670671
}

mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ open class DefaultVideoPlayerState : VideoPlayerState {
6363

6464
// Error handling
6565
override var onPlaybackEnded: (() -> Unit)? = null
66+
override var onRestart: (() -> Unit)? = null
6667

6768
private var _error by mutableStateOf<VideoPlayerError?>(null)
6869
override val error: VideoPlayerError? get() = _error
@@ -361,38 +362,14 @@ open class DefaultVideoPlayerState : VideoPlayerState {
361362
forceUpdate: Boolean = false,
362363
) {
363364
val now = TimeSource.Monotonic.markNow()
364-
if (forceUpdate || now - lastUpdateTime >= 1.seconds) {
365-
// Calculate a dynamic threshold based on video duration (10% of duration or at least 0.5 seconds)
366-
val threshold =
367-
if (duration > 0f && !duration.isNaN()) {
368-
maxOf(duration * 0.1f, 0.5f)
369-
} else {
370-
0.5f
371-
}
372-
373-
// Check if we're very close to the end of the video
374-
val isNearEnd =
375-
duration > 0f &&
376-
!duration.isNaN() &&
377-
!currentTime.isNaN() &&
378-
(duration - currentTime < threshold)
379-
380-
// If we're near the end, use the duration as the current time
381-
val displayTime = if (isNearEnd) duration else currentTime
382-
383-
_positionText = if (displayTime.isNaN()) "00:00" else formatTime(displayTime)
365+
if (forceUpdate || now - lastUpdateTime >= 250.milliseconds) {
366+
_positionText = if (currentTime.isNaN()) "00:00" else formatTime(currentTime)
384367
_durationText = if (duration.isNaN()) "00:00" else formatTime(duration)
385368

386-
// Update the current time property
387-
_currentTime = displayTime.toDouble()
369+
_currentTime = currentTime.toDouble()
388370

389371
if (!userDragging && duration > 0f && !duration.isNaN() && !_isLoading) {
390-
sliderPos =
391-
if (isNearEnd) {
392-
PERCENTAGE_MULTIPLIER // Set to 100% if near end
393-
} else {
394-
(currentTime / duration) * PERCENTAGE_MULTIPLIER
395-
}
372+
sliderPos = (currentTime / duration) * PERCENTAGE_MULTIPLIER
396373
}
397374
_currentDuration = duration
398375
lastUpdateTime = now

0 commit comments

Comments
 (0)