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) implementation(libs.vorbisspi)
} }
commonMain.dependencies { commonMain.dependencies {
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
// DI // DI
implementation(project.dependencies.platform(libs.koin.bom)) implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core) implementation(libs.koin.core)
@@ -2,13 +2,40 @@ package fr.ajaury.gwenedeg.player
import android.content.Context import android.content.Context
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidAudioPlayer( class AndroidAudioPlayer(
context: Context, context: Context,
) : AudioPlayer { ) : AudioPlayer {
private val player = ExoPlayer.Builder(context).build() 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) { override fun load(uri: String) {
val mediaItem = MediaItem.fromUri(uri) val mediaItem = MediaItem.fromUri(uri)
player.setMediaItem(mediaItem) player.setMediaItem(mediaItem)
@@ -1,6 +1,10 @@
package fr.ajaury.gwenedeg.player package fr.ajaury.gwenedeg.player
import kotlinx.coroutines.flow.StateFlow
interface AudioPlayer { interface AudioPlayer {
val playbackState: StateFlow<PlaybackState>
fun play() fun play()
fun pause() 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 package fr.ajaury.gwenedeg.player
import kotlinx.cinterop.ExperimentalForeignApi 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.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.pause import platform.AVFoundation.pause
import platform.AVFoundation.play import platform.AVFoundation.play
import platform.AVFoundation.replaceCurrentItemWithPlayerItem import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.darwin.NSObjectProtocol
class IosAudioPlayer : AudioPlayer { class IosAudioPlayer : AudioPlayer {
private var player: AVPlayer? = null 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) @OptIn(ExperimentalForeignApi::class)
override fun load(uri: String) { 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() { override fun play() {
player?.play() player?.play()
_playbackState.value = PlaybackState.PLAYING
} }
override fun pause() { override fun pause() {
player?.pause() player?.pause()
_playbackState.value = PlaybackState.PAUSED
} }
override fun stop() { override fun stop() {
@@ -31,6 +55,8 @@ class IosAudioPlayer : AudioPlayer {
} }
override fun release() { override fun release() {
endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) }
endObserver = null
player?.pause() player?.pause()
player = null player = null
} }
@@ -1,10 +1,14 @@
package fr.ajaury.gwenedeg.player 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.io.BufferedInputStream
import java.net.URI import java.net.URI
import javax.sound.sampled.AudioFormat import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem import javax.sound.sampled.AudioSystem
import javax.sound.sampled.Clip import javax.sound.sampled.Clip
import javax.sound.sampled.LineEvent
/** /**
* Desktop [AudioPlayer] backed by [javax.sound.sampled]. * Desktop [AudioPlayer] backed by [javax.sound.sampled].
@@ -16,6 +20,11 @@ import javax.sound.sampled.Clip
class JvmAudioPlayer : AudioPlayer { class JvmAudioPlayer : AudioPlayer {
private var clip: Clip? = null 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) { override fun load(uri: String) {
release() release()
@@ -33,18 +42,41 @@ class JvmAudioPlayer : AudioPlayer {
) )
val decodedInput = AudioSystem.getAudioInputStream(pcmFormat, encodedInput) 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() { override fun play() {
stoppedByUser = false
clip?.start() clip?.start()
} }
override fun pause() { override fun pause() {
stoppedByUser = true
clip?.stop() clip?.stop()
} }
override fun stop() { override fun stop() {
stoppedByUser = true
clip?.apply { clip?.apply {
stop() stop()
framePosition = 0 framePosition = 0
@@ -52,6 +84,7 @@ class JvmAudioPlayer : AudioPlayer {
} }
override fun release() { override fun release() {
stoppedByUser = true
clip?.apply { clip?.apply {
stop() stop()
close() close()
@@ -1,14 +1,25 @@
package fr.ajaury.gwenedeg.player package fr.ajaury.gwenedeg.player
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.w3c.dom.HTMLAudioElement import org.w3c.dom.HTMLAudioElement
class WebAudioPlayer : AudioPlayer { class WebAudioPlayer : AudioPlayer {
private var audio: HTMLAudioElement? = null private var audio: HTMLAudioElement? = null
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
override fun load(uri: String) { override fun load(uri: String) {
release()
audio = (document.createElement("audio") as HTMLAudioElement).apply { audio = (document.createElement("audio") as HTMLAudioElement).apply {
src = uri 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" } koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" } 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" } 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-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" } lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
@@ -1,26 +1,35 @@
package fr.ajaury.gwenedeg.player.ui package fr.ajaury.gwenedeg.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 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 import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
recordTitle: String, recordTitle: String,
viewModel: PlayerViewModel = koinViewModel(), viewModel: PlayerViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(Unit) { DisposableEffect(Unit) {
viewModel.play(filePath = "files/demat.ogg") viewModel.play(filePath = "files/demat.ogg")
onDispose { 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 -> Scaffold { innerPadding ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -35,12 +57,62 @@ fun PlayerScreen(
.fillMaxSize(), .fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text( Text(
text = "Playing: $recordTitle", text = "Playing: $recordTitle",
style = MaterialTheme.typography.headlineMedium, 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 package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.player.AudioPlayer import fr.ajaury.gwenedeg.player.AudioPlayer
import fr.ajaury.gwenedeg.player.AudioSessionManager import fr.ajaury.gwenedeg.player.AudioSessionManager
import fr.ajaury.gwenedeg.player.PlaybackState
import gwenedeg.shared.generated.resources.Res 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( class PlayerViewModel(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager, private val audioSessionManager: AudioSessionManager,
) : ViewModel() { ) : 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 { init {
audioSessionManager.activate() audioSessionManager.activate()
} }
fun play(filePath: String) { fun play(filePath: String) {
this.filePath = filePath
audioPlayer.load(uri = Res.getUri(filePath)) audioPlayer.load(uri = Res.getUri(filePath))
audioPlayer.play() audioPlayer.play()
} }
fun pause() { fun performMainPlayAction() {
audioPlayer.pause() when (uiState.value.playbackState) {
PlaybackState.PLAYING -> audioPlayer.pause()
PlaybackState.ENDED -> filePath?.let { play(it) }
PlaybackState.PAUSED, PlaybackState.IDLE -> audioPlayer.play()
}
} }
fun stop() { fun stop() {