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.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)
@@ -1,10 +1,13 @@
package fr.ajaury.gwenedeg.player
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface AudioPlayer {
val playbackState: StateFlow<PlaybackState>
fun currentPosition(): Duration
fun play()
fun pause()
@@ -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> = _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()
@@ -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> = _playbackState.asStateFlow()
override fun currentPosition(): Duration = (clip?.microsecondPosition ?: 0L).microseconds
override fun load(uri: String) {
release()
@@ -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> = _playbackState.asStateFlow()
override fun currentPosition(): Duration = (audio?.currentTime ?: 0.0).seconds
override fun load(uri: String) {
release()
@@ -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 = {},
)
@@ -4,4 +4,5 @@ import fr.ajaury.gwenedeg.player.PlaybackState
data class PlayerUiState(
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.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<PlayerUiState> = 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<PlayerUiState> = 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
}
}