new: display current playback position and implement cross-platform position tracking
This commit is contained in:
+4
@@ -7,6 +7,8 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class AndroidAudioPlayer(
|
class AndroidAudioPlayer(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -36,6 +38,8 @@ class AndroidAudioPlayer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun currentPosition(): Duration = player.currentPosition.coerceAtLeast(0).milliseconds
|
||||||
|
|
||||||
override fun load(uri: String) {
|
override fun load(uri: String) {
|
||||||
val mediaItem = MediaItem.fromUri(uri)
|
val mediaItem = MediaItem.fromUri(uri)
|
||||||
player.setMediaItem(mediaItem)
|
player.setMediaItem(mediaItem)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package fr.ajaury.gwenedeg.player
|
package fr.ajaury.gwenedeg.player
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
interface AudioPlayer {
|
interface AudioPlayer {
|
||||||
val playbackState: StateFlow<PlaybackState>
|
val playbackState: StateFlow<PlaybackState>
|
||||||
|
|
||||||
|
fun currentPosition(): Duration
|
||||||
|
|
||||||
fun play()
|
fun play()
|
||||||
|
|
||||||
fun pause()
|
fun pause()
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ 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.currentTime
|
||||||
import platform.AVFoundation.pause
|
import platform.AVFoundation.pause
|
||||||
import platform.AVFoundation.play
|
import platform.AVFoundation.play
|
||||||
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||||
|
import platform.CoreMedia.CMTimeGetSeconds
|
||||||
import platform.Foundation.NSNotificationCenter
|
import platform.Foundation.NSNotificationCenter
|
||||||
import platform.Foundation.NSOperationQueue
|
import platform.Foundation.NSOperationQueue
|
||||||
import platform.Foundation.NSURL
|
import platform.Foundation.NSURL
|
||||||
import platform.darwin.NSObjectProtocol
|
import platform.darwin.NSObjectProtocol
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class IosAudioPlayer : AudioPlayer {
|
class IosAudioPlayer : AudioPlayer {
|
||||||
private var player: AVPlayer? = null
|
private var player: AVPlayer? = null
|
||||||
@@ -22,6 +27,13 @@ class IosAudioPlayer : 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()
|
||||||
|
|
||||||
|
@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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun load(uri: String) {
|
override fun load(uri: String) {
|
||||||
release()
|
release()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import javax.sound.sampled.AudioFormat
|
|||||||
import javax.sound.sampled.AudioSystem
|
import javax.sound.sampled.AudioSystem
|
||||||
import javax.sound.sampled.Clip
|
import javax.sound.sampled.Clip
|
||||||
import javax.sound.sampled.LineEvent
|
import javax.sound.sampled.LineEvent
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.microseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop [AudioPlayer] backed by [javax.sound.sampled].
|
* Desktop [AudioPlayer] backed by [javax.sound.sampled].
|
||||||
@@ -25,6 +27,8 @@ 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 fun load(uri: String) {
|
override fun load(uri: String) {
|
||||||
release()
|
release()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.w3c.dom.HTMLAudioElement
|
import org.w3c.dom.HTMLAudioElement
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class WebAudioPlayer : AudioPlayer {
|
class WebAudioPlayer : AudioPlayer {
|
||||||
private var audio: HTMLAudioElement? = null
|
private var audio: HTMLAudioElement? = null
|
||||||
@@ -12,6 +14,8 @@ 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 fun load(uri: String) {
|
override fun load(uri: String) {
|
||||||
release()
|
release()
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ fun PlayerScreen(
|
|||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${uiState.positionMs} ms",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.size(60.dp),
|
modifier = Modifier.size(60.dp),
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
@@ -132,7 +137,7 @@ private fun PlayerScreenPlayingPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING),
|
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
@@ -145,7 +150,7 @@ private fun PlayerScreenPausedPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED),
|
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
@@ -158,7 +163,7 @@ private fun PlayerScreenEndedPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.ENDED),
|
uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
+40
-7
@@ -7,11 +7,17 @@ import fr.ajaury.gwenedeg.player.AudioPlayer
|
|||||||
import fr.ajaury.gwenedeg.player.AudioSessionManager
|
import fr.ajaury.gwenedeg.player.AudioSessionManager
|
||||||
import fr.ajaury.gwenedeg.player.PlaybackState
|
import fr.ajaury.gwenedeg.player.PlaybackState
|
||||||
import gwenedeg.shared.generated.resources.Res
|
import gwenedeg.shared.generated.resources.Res
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
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 kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class PlayerViewModel(
|
class PlayerViewModel(
|
||||||
@@ -21,13 +27,36 @@ class PlayerViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var filePath: String? = null
|
private var filePath: String? = null
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = audioPlayer.playbackState
|
private val playbackState = audioPlayer.playbackState
|
||||||
.map { PlayerUiState(playbackState = it) }
|
|
||||||
.stateIn(
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
scope = viewModelScope,
|
private val currentPosition = playbackState
|
||||||
started = SharingStarted.WhileSubscribed(5.seconds),
|
.flatMapLatest { state ->
|
||||||
initialValue = PlayerUiState(),
|
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 {
|
init {
|
||||||
audioSessionManager.activate()
|
audioSessionManager.activate()
|
||||||
@@ -60,4 +89,8 @@ class PlayerViewModel(
|
|||||||
audioSessionManager.deactivate()
|
audioSessionManager.deactivate()
|
||||||
audioPlayer.release()
|
audioPlayer.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val POSITION_POLL_INTERVAL = 200.milliseconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user