new: display playback progress with a progress bar

This commit is contained in:
2026-06-19 13:38:32 +02:00
parent 090305dbde
commit 511e3b5b9a
8 changed files with 61 additions and 20 deletions
@@ -1,6 +1,7 @@
package fr.ajaury.gwenedeg.player package fr.ajaury.gwenedeg.player
import android.content.Context import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer 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) { override fun load(uri: String) {
val mediaItem = MediaItem.fromUri(uri) val mediaItem = MediaItem.fromUri(uri)
@@ -6,7 +6,9 @@ import kotlin.time.Duration
interface AudioPlayer { interface AudioPlayer {
val playbackState: StateFlow<PlaybackState> val playbackState: StateFlow<PlaybackState>
fun currentPosition(): Duration val currentPosition: Duration
val duration: Duration
fun play() fun play()
@@ -7,7 +7,9 @@ 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.currentItem
import platform.AVFoundation.currentTime import platform.AVFoundation.currentTime
import platform.AVFoundation.duration
import platform.AVFoundation.pause import platform.AVFoundation.pause
import platform.AVFoundation.play import platform.AVFoundation.play
import platform.AVFoundation.replaceCurrentItemWithPlayerItem import platform.AVFoundation.replaceCurrentItemWithPlayerItem
@@ -28,11 +30,20 @@ class IosAudioPlayer : AudioPlayer {
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow() override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
override fun currentPosition(): Duration { override val currentPosition: Duration
val player = player ?: return 0.milliseconds get() {
val seconds = CMTimeGetSeconds(player.currentTime()).takeIf { !it.isNaN() } val player = player ?: return 0.milliseconds
return seconds?.seconds ?: 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) @OptIn(ExperimentalForeignApi::class)
override fun load(uri: String) { override fun load(uri: String) {
@@ -27,7 +27,11 @@ 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 val currentPosition: Duration
get() = (clip?.microsecondPosition ?: 0L).microseconds
override val duration: Duration
get() = (clip?.microsecondLength ?: 0L).microseconds
override fun load(uri: String) { override fun load(uri: String) {
release() release()
@@ -14,7 +14,11 @@ 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 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) { override fun load(uri: String) {
release() release()
@@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -16,6 +18,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -85,7 +88,8 @@ fun PlayerScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
@@ -97,9 +101,11 @@ fun PlayerScreen(
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
) )
Text( Spacer(modifier = Modifier.weight(1f))
text = "${uiState.positionMs} ms",
style = MaterialTheme.typography.bodyMedium, LinearProgressIndicator(
progress = { uiState.progress },
modifier = Modifier.fillMaxWidth(),
) )
Button( Button(
@@ -137,7 +143,7 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234), uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -150,7 +156,7 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234), uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -163,7 +169,7 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", recordTitle = "Demat",
uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234), uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onBackClicked = {}, onBackClicked = {},
) )
@@ -5,4 +5,8 @@ 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, val positionMs: Long = 0L,
) val durationMs: Long = 0L,
) {
val progress: Float
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
}
@@ -35,12 +35,12 @@ class PlayerViewModel(
if (state == PlaybackState.PLAYING) { if (state == PlaybackState.PLAYING) {
flow { flow {
while (true) { while (true) {
emit(audioPlayer.currentPosition()) emit(audioPlayer.currentPosition)
delay(POSITION_POLL_INTERVAL) delay(POSITION_POLL_INTERVAL)
} }
} }
} else { } else {
flowOf(audioPlayer.currentPosition()) flowOf(audioPlayer.currentPosition)
} }
} }
@@ -51,6 +51,7 @@ class PlayerViewModel(
PlayerUiState( PlayerUiState(
playbackState = playbackState, playbackState = playbackState,
positionMs = currentPositionMs.inWholeMilliseconds, positionMs = currentPositionMs.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -91,6 +92,6 @@ class PlayerViewModel(
} }
companion object { companion object {
private val POSITION_POLL_INTERVAL = 200.milliseconds private val POSITION_POLL_INTERVAL = 50.milliseconds
} }
} }