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
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)
@@ -6,7 +6,9 @@ import kotlin.time.Duration
interface AudioPlayer {
val playbackState: StateFlow<PlaybackState>
fun currentPosition(): Duration
val currentPosition: Duration
val duration: Duration
fun play()
@@ -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> = _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) {
@@ -27,7 +27,11 @@ class JvmAudioPlayer : AudioPlayer {
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
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) {
release()
@@ -14,7 +14,11 @@ class WebAudioPlayer : AudioPlayer {
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
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) {
release()
@@ -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 = {},
)
@@ -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
}
@@ -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
}
}