new: add playback state handling across platforms and integrate main play action in PlayerScreen

This commit is contained in:
2026-06-17 17:46:12 +02:00
parent 1fc8fbbc0d
commit 51a9f2f80d
11 changed files with 223 additions and 8 deletions
+3
View File
@@ -48,6 +48,9 @@ kotlin {
implementation(libs.vorbisspi)
}
commonMain.dependencies {
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
@@ -2,13 +2,40 @@ package fr.ajaury.gwenedeg.player
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidAudioPlayer(
context: Context,
) : AudioPlayer {
private val player = ExoPlayer.Builder(context).build()
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
init {
player.addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
_playbackState.value = PlaybackState.ENDED
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
_playbackState.value = when {
isPlaying -> PlaybackState.PLAYING
player.playbackState == Player.STATE_ENDED -> PlaybackState.ENDED
else -> PlaybackState.PAUSED
}
}
},
)
}
override fun load(uri: String) {
val mediaItem = MediaItem.fromUri(uri)
player.setMediaItem(mediaItem)
@@ -1,6 +1,10 @@
package fr.ajaury.gwenedeg.player
import kotlinx.coroutines.flow.StateFlow
interface AudioPlayer {
val playbackState: StateFlow<PlaybackState>
fun play()
fun pause()
@@ -0,0 +1,8 @@
package fr.ajaury.gwenedeg.player
enum class PlaybackState {
IDLE,
PLAYING,
PAUSED,
ENDED,
}
@@ -1,28 +1,52 @@
package fr.ajaury.gwenedeg.player
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
import platform.Foundation.NSURL
import platform.darwin.NSObjectProtocol
class IosAudioPlayer : AudioPlayer {
private var player: AVPlayer? = null
private var endObserver: NSObjectProtocol? = null
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@OptIn(ExperimentalForeignApi::class)
override fun load(uri: String) {
val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri")
release()
player = AVPlayer(nsUrl)
val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri")
val item = AVPlayerItem(uRL = nsUrl)
player = AVPlayer(playerItem = item)
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVPlayerItemDidPlayToEndTimeNotification,
`object` = item,
queue = NSOperationQueue.mainQueue,
) {
_playbackState.value = PlaybackState.ENDED
}
}
override fun play() {
player?.play()
_playbackState.value = PlaybackState.PLAYING
}
override fun pause() {
player?.pause()
_playbackState.value = PlaybackState.PAUSED
}
override fun stop() {
@@ -31,6 +55,8 @@ class IosAudioPlayer : AudioPlayer {
}
override fun release() {
endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
endObserver = null
player?.pause()
player = null
}
@@ -1,10 +1,14 @@
package fr.ajaury.gwenedeg.player
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.BufferedInputStream
import java.net.URI
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.Clip
import javax.sound.sampled.LineEvent
/**
* Desktop [AudioPlayer] backed by [javax.sound.sampled].
@@ -16,6 +20,11 @@ import javax.sound.sampled.Clip
class JvmAudioPlayer : AudioPlayer {
private var clip: Clip? = null
private var stoppedByUser = false
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
override fun load(uri: String) {
release()
@@ -33,18 +42,41 @@ class JvmAudioPlayer : AudioPlayer {
)
val decodedInput = AudioSystem.getAudioInputStream(pcmFormat, encodedInput)
clip = AudioSystem.getClip().apply { open(decodedInput) }
clip = AudioSystem.getClip().apply {
addLineListener { event ->
// Listener events arrive asynchronously; ignore those from a clip we replaced.
if (event.line !== clip) return@addLineListener
// A clip emits the same `STOP` event whether it was paused/stopped by us or it reached the
// end on its own, and the decoded frame length is unreliable for Vorbis. We therefore track
// whether the last stop was user-initiated to tell a pause apart from a natural end.
when (event.type) {
LineEvent.Type.START -> {
_playbackState.value = PlaybackState.PLAYING
}
LineEvent.Type.STOP -> {
_playbackState.value =
if (stoppedByUser) PlaybackState.PAUSED else PlaybackState.ENDED
}
}
}
open(decodedInput)
}
}
override fun play() {
stoppedByUser = false
clip?.start()
}
override fun pause() {
stoppedByUser = true
clip?.stop()
}
override fun stop() {
stoppedByUser = true
clip?.apply {
stop()
framePosition = 0
@@ -52,6 +84,7 @@ class JvmAudioPlayer : AudioPlayer {
}
override fun release() {
stoppedByUser = true
clip?.apply {
stop()
close()
@@ -1,14 +1,25 @@
package fr.ajaury.gwenedeg.player
import kotlinx.browser.document
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.w3c.dom.HTMLAudioElement
class WebAudioPlayer : AudioPlayer {
private var audio: HTMLAudioElement? = null
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
override fun load(uri: String) {
release()
audio = (document.createElement("audio") as HTMLAudioElement).apply {
src = uri
addEventListener("play") { _playbackState.value = PlaybackState.PLAYING }
addEventListener("ended") { _playbackState.value = PlaybackState.ENDED }
addEventListener("pause") { _playbackState.value = PlaybackState.PAUSED }
}
}