new: add playback state handling across platforms and integrate main play action in PlayerScreen
This commit is contained in:
@@ -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)
|
||||
|
||||
+27
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+25
-2
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user