new: display current playback position and implement cross-platform position tracking

This commit is contained in:
2026-06-19 12:23:51 +02:00
parent bc848ebb62
commit 090305dbde
8 changed files with 76 additions and 10 deletions
@@ -7,6 +7,8 @@ import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class AndroidAudioPlayer( class AndroidAudioPlayer(
context: Context, context: Context,
@@ -36,6 +38,8 @@ class AndroidAudioPlayer(
) )
} }
override fun currentPosition(): Duration = player.currentPosition.coerceAtLeast(0).milliseconds
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,10 +1,13 @@
package fr.ajaury.gwenedeg.player package fr.ajaury.gwenedeg.player
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface AudioPlayer { interface AudioPlayer {
val playbackState: StateFlow<PlaybackState> val playbackState: StateFlow<PlaybackState>
fun currentPosition(): Duration
fun play() fun play()
fun pause() fun pause()
@@ -7,13 +7,18 @@ import kotlinx.coroutines.flow.asStateFlow
import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.currentTime
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.CoreMedia.CMTimeGetSeconds
import platform.Foundation.NSNotificationCenter import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue import platform.Foundation.NSOperationQueue
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.darwin.NSObjectProtocol import platform.darwin.NSObjectProtocol
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class IosAudioPlayer : AudioPlayer { class IosAudioPlayer : AudioPlayer {
private var player: AVPlayer? = null private var player: AVPlayer? = null
@@ -22,6 +27,13 @@ class IosAudioPlayer : AudioPlayer {
private val _playbackState = MutableStateFlow(PlaybackState.IDLE) private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow() override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@OptIn(ExperimentalForeignApi::class)
override fun currentPosition(): Duration {
val player = player ?: return 0.milliseconds
val seconds = CMTimeGetSeconds(player.currentTime()).takeIf { !it.isNaN() }
return seconds?.seconds ?: 0.milliseconds
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
override fun load(uri: String) { override fun load(uri: String) {
release() release()
@@ -9,6 +9,8 @@ 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 import javax.sound.sampled.LineEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
/** /**
* Desktop [AudioPlayer] backed by [javax.sound.sampled]. * Desktop [AudioPlayer] backed by [javax.sound.sampled].
@@ -25,6 +27,8 @@ class JvmAudioPlayer : AudioPlayer {
private val _playbackState = MutableStateFlow(PlaybackState.IDLE) private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow() override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
override fun currentPosition(): Duration = (clip?.microsecondPosition ?: 0L).microseconds
override fun load(uri: String) { override fun load(uri: String) {
release() release()
@@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.w3c.dom.HTMLAudioElement import org.w3c.dom.HTMLAudioElement
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class WebAudioPlayer : AudioPlayer { class WebAudioPlayer : AudioPlayer {
private var audio: HTMLAudioElement? = null private var audio: HTMLAudioElement? = null
@@ -12,6 +14,8 @@ class WebAudioPlayer : AudioPlayer {
private val _playbackState = MutableStateFlow(PlaybackState.IDLE) private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow() override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
override fun currentPosition(): Duration = (audio?.currentTime ?: 0.0).seconds
override fun load(uri: String) { override fun load(uri: String) {
release() release()
@@ -97,6 +97,11 @@ fun PlayerScreen(
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
) )
Text(
text = "${uiState.positionMs} ms",
style = MaterialTheme.typography.bodyMedium,
)
Button( Button(
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
@@ -132,7 +137,7 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING), uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -145,7 +150,7 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED), uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -158,7 +163,7 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.ENDED), uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -4,4 +4,5 @@ import fr.ajaury.gwenedeg.player.PlaybackState
data class PlayerUiState( data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE, val playbackState: PlaybackState = PlaybackState.IDLE,
val positionMs: Long = 0L,
) )
@@ -7,11 +7,17 @@ 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 fr.ajaury.gwenedeg.player.PlaybackState
import gwenedeg.shared.generated.resources.Res import gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class PlayerViewModel( class PlayerViewModel(
@@ -21,13 +27,36 @@ class PlayerViewModel(
) : ViewModel() { ) : ViewModel() {
private var filePath: String? = null private var filePath: String? = null
val uiState: StateFlow<PlayerUiState> = audioPlayer.playbackState private val playbackState = audioPlayer.playbackState
.map { PlayerUiState(playbackState = it) }
.stateIn( @OptIn(ExperimentalCoroutinesApi::class)
scope = viewModelScope, private val currentPosition = playbackState
started = SharingStarted.WhileSubscribed(5.seconds), .flatMapLatest { state ->
initialValue = PlayerUiState(), if (state == PlaybackState.PLAYING) {
flow {
while (true) {
emit(audioPlayer.currentPosition())
delay(POSITION_POLL_INTERVAL)
}
}
} else {
flowOf(audioPlayer.currentPosition())
}
}
val uiState: StateFlow<PlayerUiState> = combine(
playbackState,
currentPosition,
) { playbackState, currentPositionMs ->
PlayerUiState(
playbackState = playbackState,
positionMs = currentPositionMs.inWholeMilliseconds,
) )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = PlayerUiState(),
)
init { init {
audioSessionManager.activate() audioSessionManager.activate()
@@ -60,4 +89,8 @@ class PlayerViewModel(
audioSessionManager.deactivate() audioSessionManager.deactivate()
audioPlayer.release() audioPlayer.release()
} }
companion object {
private val POSITION_POLL_INTERVAL = 200.milliseconds
}
} }