From 511e3b5b9a574fff5cc132220d2ca4a613f69157 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 19 Jun 2026 13:38:32 +0200 Subject: [PATCH] new: display playback progress with a progress bar --- .../gwenedeg/player/AndroidAudioPlayer.kt | 11 +++++++++- .../fr/ajaury/gwenedeg/player/AudioPlayer.kt | 4 +++- .../ajaury/gwenedeg/player/IosAudioPlayer.kt | 21 ++++++++++++++----- .../ajaury/gwenedeg/player/JvmAudioPlayer.kt | 6 +++++- .../ajaury/gwenedeg/player/WebAudioPlayer.kt | 6 +++++- .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 20 +++++++++++------- .../player/ui/viewmodel/PlayerUiState.kt | 6 +++++- .../player/ui/viewmodel/PlayerViewModel.kt | 7 ++++--- 8 files changed, 61 insertions(+), 20 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 522bd0a..e98709f 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 @@ -1,6 +1,7 @@ package fr.ajaury.gwenedeg.player import android.content.Context +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -38,7 +39,15 @@ class AndroidAudioPlayer( ) } - override fun currentPosition(): Duration = player.currentPosition.coerceAtLeast(0).milliseconds + override val currentPosition: Duration + get() = player.currentPosition.coerceAtLeast(0).milliseconds + + override val duration: Duration + get() = player.duration + .takeIf { it != C.TIME_UNSET } + ?.coerceAtLeast(0) + ?.milliseconds + ?: 0.milliseconds override fun load(uri: String) { val mediaItem = MediaItem.fromUri(uri) 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 240548f..8dbcec4 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 @@ -6,7 +6,9 @@ import kotlin.time.Duration interface AudioPlayer { val playbackState: StateFlow - fun currentPosition(): Duration + val currentPosition: Duration + + val duration: Duration fun play() 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 2c274e0..92ce794 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,7 +7,9 @@ import kotlinx.coroutines.flow.asStateFlow import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification +import platform.AVFoundation.currentItem import platform.AVFoundation.currentTime +import platform.AVFoundation.duration import platform.AVFoundation.pause import platform.AVFoundation.play import platform.AVFoundation.replaceCurrentItemWithPlayerItem @@ -28,11 +30,20 @@ class IosAudioPlayer : AudioPlayer { 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 - } + override val currentPosition: Duration + get() { + val player = player ?: return 0.milliseconds + val seconds = CMTimeGetSeconds(player.currentTime()).takeIf { !it.isNaN() } + return seconds?.seconds ?: 0.milliseconds + } + + @OptIn(ExperimentalForeignApi::class) + override val duration: Duration + get() { + val item = player?.currentItem ?: return 0.milliseconds + val seconds = CMTimeGetSeconds(item.duration).takeIf { it.isFinite() } + return seconds?.seconds ?: 0.milliseconds + } @OptIn(ExperimentalForeignApi::class) override fun load(uri: String) { 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 099a5bd..061d277 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 @@ -27,7 +27,11 @@ class JvmAudioPlayer : AudioPlayer { private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() - override fun currentPosition(): Duration = (clip?.microsecondPosition ?: 0L).microseconds + override val currentPosition: Duration + get() = (clip?.microsecondPosition ?: 0L).microseconds + + override val duration: Duration + get() = (clip?.microsecondLength ?: 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 0e6d328..4949677 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 @@ -14,7 +14,11 @@ 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 val currentPosition: Duration + get() = (audio?.currentTime ?: 0.0).seconds + + override val duration: Duration + get() = (audio?.duration?.takeIf { it.isFinite() } ?: 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 1cdbdc7..f1d5190 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 @@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -16,6 +18,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -85,7 +88,8 @@ fun PlayerScreen( Box( modifier = Modifier .padding(innerPadding) - .fillMaxSize(), + .fillMaxSize() + .padding(16.dp), contentAlignment = Alignment.Center, ) { Column( @@ -97,9 +101,11 @@ fun PlayerScreen( style = MaterialTheme.typography.headlineMedium, ) - Text( - text = "${uiState.positionMs} ms", - style = MaterialTheme.typography.bodyMedium, + Spacer(modifier = Modifier.weight(1f)) + + LinearProgressIndicator( + progress = { uiState.progress }, + modifier = Modifier.fillMaxWidth(), ) Button( @@ -137,7 +143,7 @@ private fun PlayerScreenPlayingPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234), + uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000), onMainPlayActionButtonClicked = {}, onBackClicked = {}, ) @@ -150,7 +156,7 @@ private fun PlayerScreenPausedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234), + uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000), onMainPlayActionButtonClicked = {}, onBackClicked = {}, ) @@ -163,7 +169,7 @@ private fun PlayerScreenEndedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234), + uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000), 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 0f4290b..1eef80f 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 @@ -5,4 +5,8 @@ import fr.ajaury.gwenedeg.player.PlaybackState data class PlayerUiState( val playbackState: PlaybackState = PlaybackState.IDLE, val positionMs: Long = 0L, -) + val durationMs: Long = 0L, +) { + val progress: Float + get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f +} 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 2f06c41..28d318d 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 @@ -35,12 +35,12 @@ class PlayerViewModel( if (state == PlaybackState.PLAYING) { flow { while (true) { - emit(audioPlayer.currentPosition()) + emit(audioPlayer.currentPosition) delay(POSITION_POLL_INTERVAL) } } } else { - flowOf(audioPlayer.currentPosition()) + flowOf(audioPlayer.currentPosition) } } @@ -51,6 +51,7 @@ class PlayerViewModel( PlayerUiState( playbackState = playbackState, positionMs = currentPositionMs.inWholeMilliseconds, + durationMs = audioPlayer.duration.inWholeMilliseconds, ) }.stateIn( scope = viewModelScope, @@ -91,6 +92,6 @@ class PlayerViewModel( } companion object { - private val POSITION_POLL_INTERVAL = 200.milliseconds + private val POSITION_POLL_INTERVAL = 50.milliseconds } }