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 }
}
}
+1
View File
@@ -34,6 +34,7 @@ koin-core = { module = "io.insert-koin:koin-core" }
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" }
lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
@@ -1,26 +1,35 @@
package fr.ajaury.gwenedeg.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.ajaury.gwenedeg.player.PlaybackState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import org.jetbrains.compose.resources.ExperimentalResourceApi
import fr.ajaury.gwenedeg.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalResourceApi::class)
@Composable
fun PlayerScreen(
recordTitle: String,
viewModel: PlayerViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(Unit) {
viewModel.play(filePath = "files/demat.ogg")
onDispose {
@@ -28,6 +37,19 @@ fun PlayerScreen(
}
}
PlayerScreen(
recordTitle = recordTitle,
uiState = uiState,
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
)
}
@Composable
fun PlayerScreen(
recordTitle: String,
uiState: PlayerUiState,
onMainPlayActionButtonClicked: () -> Unit,
) {
Scaffold { innerPadding ->
Box(
modifier = Modifier
@@ -35,12 +57,62 @@ fun PlayerScreen(
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = "Playing: $recordTitle",
style = MaterialTheme.typography.headlineMedium,
)
Button(onClick = onMainPlayActionButtonClicked) {
Text(text = uiState.playbackState.toActionLabel())
}
}
}
}
}
private fun PlaybackState.toActionLabel(): String =
when (this) {
PlaybackState.PLAYING -> "Pause"
PlaybackState.ENDED -> "Replay"
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
}
@Preview
@Composable
private fun PlayerScreenPlayingPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING),
onMainPlayActionButtonClicked = {},
)
}
}
@Preview
@Composable
private fun PlayerScreenPausedPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED),
onMainPlayActionButtonClicked = {},
)
}
}
@Preview
@Composable
private fun PlayerScreenEndedPreview() {
GwenedegTheme {
PlayerScreen(
recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.ENDED),
onMainPlayActionButtonClicked = {},
)
}
}
@@ -0,0 +1,7 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.PlaybackState
data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE,
)
@@ -1,25 +1,48 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.player.AudioPlayer
import fr.ajaury.gwenedeg.player.AudioSessionManager
import fr.ajaury.gwenedeg.player.PlaybackState
import gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Duration.Companion.seconds
class PlayerViewModel(
private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager,
) : ViewModel() {
private var filePath: String? = null
val uiState: StateFlow<PlayerUiState> = audioPlayer.playbackState
.map { PlayerUiState(playbackState = it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = PlayerUiState(),
)
init {
audioSessionManager.activate()
}
fun play(filePath: String) {
this.filePath = filePath
audioPlayer.load(uri = Res.getUri(filePath))
audioPlayer.play()
}
fun pause() {
audioPlayer.pause()
fun performMainPlayAction() {
when (uiState.value.playbackState) {
PlaybackState.PLAYING -> audioPlayer.pause()
PlaybackState.ENDED -> filePath?.let { play(it) }
PlaybackState.PAUSED, PlaybackState.IDLE -> audioPlayer.play()
}
}
fun stop() {