Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -340,6 +341,7 @@ open class DefaultVideoPlayerState(
if (finished) {
dispatch_async(dispatch_get_main_queue()) {
player.playImmediatelyAtRate(_playbackSpeed)
onRestart?.invoke()
}
}
}
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -576,6 +577,7 @@ class LinuxVideoPlayerState : VideoPlayerState {

if (loop) {
seekToAsync(0f)
onRestart?.invoke()
} else {
withContext(Dispatchers.Main) { isPlaying = false }
pauseInBackground()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VideoPlayerError?>(null)
override val error: VideoPlayerError? get() = _error
Expand Down Expand Up @@ -361,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
Expand Down
Loading