From 090305dbdeeb5dcc0bc8ec93f3bd26c29fe8d468 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 19 Jun 2026 12:23:51 +0200 Subject: [PATCH] new: display current playback position and implement cross-platform position tracking --- .../gwenedeg/player/AndroidAudioPlayer.kt | 4 ++ .../fr/ajaury/gwenedeg/player/AudioPlayer.kt | 3 ++ .../ajaury/gwenedeg/player/IosAudioPlayer.kt | 12 +++++ .../ajaury/gwenedeg/player/JvmAudioPlayer.kt | 4 ++ .../ajaury/gwenedeg/player/WebAudioPlayer.kt | 4 ++ .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 11 +++-- .../player/ui/viewmodel/PlayerUiState.kt | 1 + .../player/ui/viewmodel/PlayerViewModel.kt | 47 ++++++++++++++++--- 8 files changed, 76 insertions(+), 10 deletions(-) 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 05f1281..522bd0a 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 @@ -7,6 +7,8 @@ import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds class AndroidAudioPlayer( context: Context, @@ -36,6 +38,8 @@ class AndroidAudioPlayer( ) } + override fun currentPosition(): Duration = player.currentPosition.coerceAtLeast(0).milliseconds + 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 e5bc582..240548f 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,10 +1,13 @@ package fr.ajaury.gwenedeg.player import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration interface AudioPlayer { val playbackState: StateFlow + fun currentPosition(): Duration + fun play() fun pause() 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 a8eeac7..2c274e0 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 @@ -7,13 +7,18 @@ import kotlinx.coroutines.flow.asStateFlow import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification +import platform.AVFoundation.currentTime import platform.AVFoundation.pause import platform.AVFoundation.play import platform.AVFoundation.replaceCurrentItemWithPlayerItem +import platform.CoreMedia.CMTimeGetSeconds import platform.Foundation.NSNotificationCenter import platform.Foundation.NSOperationQueue import platform.Foundation.NSURL import platform.darwin.NSObjectProtocol +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class IosAudioPlayer : AudioPlayer { private var player: AVPlayer? = null @@ -22,6 +27,13 @@ class IosAudioPlayer : AudioPlayer { private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _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) override fun load(uri: String) { release() 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 6106f83..099a5bd 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 @@ -9,6 +9,8 @@ import javax.sound.sampled.AudioFormat import javax.sound.sampled.AudioSystem import javax.sound.sampled.Clip import javax.sound.sampled.LineEvent +import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds /** * Desktop [AudioPlayer] backed by [javax.sound.sampled]. @@ -25,6 +27,8 @@ class JvmAudioPlayer : AudioPlayer { private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() + override fun currentPosition(): Duration = (clip?.microsecondPosition ?: 0L).microseconds + override fun load(uri: String) { release() 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 f1ccc29..0e6d328 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 @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.w3c.dom.HTMLAudioElement +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class WebAudioPlayer : AudioPlayer { private var audio: HTMLAudioElement? = null @@ -12,6 +14,8 @@ class WebAudioPlayer : AudioPlayer { private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() + override fun currentPosition(): Duration = (audio?.currentTime ?: 0.0).seconds + override fun load(uri: String) { release() 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 4ce093c..1cdbdc7 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 @@ -97,6 +97,11 @@ fun PlayerScreen( style = MaterialTheme.typography.headlineMedium, ) + Text( + text = "${uiState.positionMs} ms", + style = MaterialTheme.typography.bodyMedium, + ) + Button( modifier = Modifier.size(60.dp), contentPadding = PaddingValues(0.dp), @@ -132,7 +137,7 @@ private fun PlayerScreenPlayingPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PLAYING), + uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234), onMainPlayActionButtonClicked = {}, onBackClicked = {}, ) @@ -145,7 +150,7 @@ private fun PlayerScreenPausedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PAUSED), + uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234), onMainPlayActionButtonClicked = {}, onBackClicked = {}, ) @@ -158,7 +163,7 @@ private fun PlayerScreenEndedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.ENDED), + uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234), onMainPlayActionButtonClicked = {}, onBackClicked = {}, ) 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 index c03ef60..0f4290b 100644 --- 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 @@ -4,4 +4,5 @@ import fr.ajaury.gwenedeg.player.PlaybackState data class PlayerUiState( val playbackState: PlaybackState = PlaybackState.IDLE, + val positionMs: Long = 0L, ) 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 40ca0d0..2f06c41 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 @@ -7,11 +7,17 @@ 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow 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 kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class PlayerViewModel( @@ -21,13 +27,36 @@ class PlayerViewModel( ) : 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(), + private val playbackState = audioPlayer.playbackState + + @OptIn(ExperimentalCoroutinesApi::class) + private val currentPosition = playbackState + .flatMapLatest { state -> + if (state == PlaybackState.PLAYING) { + flow { + while (true) { + emit(audioPlayer.currentPosition()) + delay(POSITION_POLL_INTERVAL) + } + } + } else { + flowOf(audioPlayer.currentPosition()) + } + } + + val uiState: StateFlow = combine( + playbackState, + currentPosition, + ) { playbackState, currentPositionMs -> + PlayerUiState( + playbackState = playbackState, + positionMs = currentPositionMs.inWholeMilliseconds, ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = PlayerUiState(), + ) init { audioSessionManager.activate() @@ -60,4 +89,8 @@ class PlayerViewModel( audioSessionManager.deactivate() audioPlayer.release() } + + companion object { + private val POSITION_POLL_INTERVAL = 200.milliseconds + } }