diff --git a/core/audioplayer/build.gradle.kts b/core/audioplayer/build.gradle.kts index 89276af..9973b9c 100644 --- a/core/audioplayer/build.gradle.kts +++ b/core/audioplayer/build.gradle.kts @@ -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) diff --git a/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/AndroidAudioPlayer.kt b/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/AndroidAudioPlayer.kt index f825f66..05f1281 100644 --- a/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/AndroidAudioPlayer.kt +++ b/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/AndroidAudioPlayer.kt @@ -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.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) diff --git a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/AudioPlayer.kt b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/AudioPlayer.kt index a5482d6..e5bc582 100644 --- a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/AudioPlayer.kt +++ b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/AudioPlayer.kt @@ -1,6 +1,10 @@ package fr.ajaury.gwenedeg.player +import kotlinx.coroutines.flow.StateFlow + interface AudioPlayer { + val playbackState: StateFlow + fun play() fun pause() diff --git a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/PlaybackState.kt b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/PlaybackState.kt new file mode 100644 index 0000000..362cffc --- /dev/null +++ b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/PlaybackState.kt @@ -0,0 +1,8 @@ +package fr.ajaury.gwenedeg.player + +enum class PlaybackState { + IDLE, + PLAYING, + PAUSED, + ENDED, +} diff --git a/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/IosAudioPlayer.kt b/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/IosAudioPlayer.kt index 79b7c63..a8eeac7 100644 --- a/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/IosAudioPlayer.kt +++ b/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/IosAudioPlayer.kt @@ -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.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 } diff --git a/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/JvmAudioPlayer.kt b/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/JvmAudioPlayer.kt index 58b368d..6106f83 100644 --- a/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/JvmAudioPlayer.kt +++ b/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/JvmAudioPlayer.kt @@ -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.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() diff --git a/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/WebAudioPlayer.kt b/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/WebAudioPlayer.kt index 0294128..f1ccc29 100644 --- a/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/WebAudioPlayer.kt +++ b/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/WebAudioPlayer.kt @@ -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.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 } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c634df..e8b5768 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt index 5952a3f..8c3acbc 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt @@ -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 = {}, + ) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt new file mode 100644 index 0000000..c03ef60 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index 21e188a..4726bf0 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -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 = 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() {