feat: beat annotator

This commit is contained in:
2026-05-15 09:03:20 +02:00
parent 7beea662b6
commit 848f5919c8
12 changed files with 640 additions and 42 deletions

View File

@@ -13,18 +13,34 @@ import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is
* the same timeline used for seeking and end-of-track reporting — see Media3
* [Player] / [ExoPlayer] documentation.
*/
class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var mediaSession: MediaSessionCompat
@@ -32,12 +48,66 @@ class PlaybackService : Service() {
private val binder = LocalBinder()
private var player: ExoPlayer? = null
private var positionPollJob: Job? = null
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
private val _trackBoundary =
MutableSharedFlow<TrackBoundaryEvent>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val trackBoundaryEvents: SharedFlow<TrackBoundaryEvent> = _trackBoundary.asSharedFlow()
private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0
private val playerListener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
if (queue.isEmpty()) {
return
}
if (index < queue.lastIndex) {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
)
index++
publishCurrentTrack()
} else {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.LAST_TRACK_FINISHED,
)
setPlaying(false)
}
}
}
}
private fun emitTrackBoundaryForQueueIndex(
i: Int,
reason: TrackBoundaryReason,
) {
val item = queue.getOrNull(i) ?: return
scope.launch {
_trackBoundary.emit(
TrackBoundaryEvent(
trackId = item.id,
title = item.title,
artist = item.artist,
queueIndex = i,
queueSize = queue.size,
reason = reason,
),
)
}
}
inner class LocalBinder : Binder() {
fun getService(): PlaybackService = this@PlaybackService
}
@@ -65,6 +135,12 @@ class PlaybackService : Service() {
override fun onSkipToPrevious() {
skipDelta(-1)
}
override fun onSeekTo(pos: Long) {
val p = player ?: return
p.seekTo(pos)
updateProgressFromPlayer()
}
},
)
mediaSession.isActive = true
@@ -115,6 +191,40 @@ class PlaybackService : Service() {
}
}
private fun ensurePlayer(): ExoPlayer {
player?.let {
return it
}
return ExoPlayer.Builder(this)
.build()
.also {
it.addListener(playerListener)
player = it
startPositionPolling()
}
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
if (player != null && queue.isNotEmpty()) {
updateProgressFromPlayer()
}
}
}
}
private fun releasePlayer() {
positionPollJob?.cancel()
positionPollJob = null
player?.removeListener(playerListener)
player?.release()
player = null
}
private fun startPlaylist(pid: String) {
scope.launch(Dispatchers.IO) {
val rows = app.playlistRepository.getTracks(pid)
@@ -128,45 +238,66 @@ class PlaybackService : Service() {
if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
return@mapNotNull null
}
val hint =
row.durationMs?.takeIf { it > 0 }
?: DEFAULT_DURATION_HINT_MS
TrackQueueItem(
id = tid,
title = row.trackName ?: "",
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
durationMsHint = hint,
)
}
index = 0
if (queue.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
withContext(Dispatchers.Main) {
releasePlayer()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return@launch
}
withContext(Dispatchers.Main) {
ensurePlayer()
setPlaying(true)
publishCurrentTrack()
refreshForegroundNotification()
}
}
}
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
val durationSec = 180
applyCurrentMediaItem(item)
val durationSec =
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value =
PlaybackUiState(
title = item.title,
artist = item.artist,
progress = _uiState.value.progress,
progress = 0f,
durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentQueueIndex = index,
queueSize = queue.size,
)
updateSessionMetadata(
item,
durationSec,
)
updatePlaybackState()
updateProgressFromPlayer()
updateSessionMetadata(item, durationSec)
updatePlaybackStateFromPlayer()
refreshForegroundNotification()
}
private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return
val p = ensurePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
p.playWhenReady = _uiState.value.isPlaying
}
private fun updateSessionMetadata(
item: TrackQueueItem,
durationSec: Int,
@@ -180,14 +311,47 @@ class PlaybackService : Service() {
)
}
private fun updatePlaybackState() {
private fun currentDurationMs(): Long {
val p = player
val fromPlayer = p?.duration?.takeIf { it > 0 }
if (fromPlayer != null) {
return fromPlayer
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun updateProgressFromPlayer() {
val p = player ?: return
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = p.currentPosition.coerceIn(0L, durationMs)
if (queue.isEmpty()) {
return
}
val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1)
_uiState.value =
_uiState.value.copy(
progress = progress,
durationSeconds = durationSec,
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
currentQueueIndex = index,
queueSize = queue.size,
)
updatePlaybackStateFromPlayer()
}
private fun updatePlaybackStateFromPlayer() {
val p = player
val positionMs = p?.currentPosition ?: 0L
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
val state =
if (_uiState.value.isPlaying) {
if (_uiState.value.isPlaying && p?.isPlaying == true) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
@@ -195,14 +359,15 @@ class PlaybackService : Service() {
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.setState(state, positionMs, 1f)
.build(),
)
}
private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing)
updatePlaybackState()
player?.playWhenReady = playing
updatePlaybackStateFromPlayer()
refreshForegroundNotification()
}
@@ -212,8 +377,16 @@ class PlaybackService : Service() {
}
private fun skipDelta(delta: Int) {
if (queue.isEmpty()) return
index = (index + delta).coerceIn(0, queue.lastIndex)
if (queue.isEmpty()) {
return
}
val oldIndex = index
val newIndex = (oldIndex + delta).coerceIn(0, queue.lastIndex)
if (newIndex == oldIndex) {
return
}
emitTrackBoundaryForQueueIndex(oldIndex, TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK)
index = newIndex
publishCurrentTrack()
}
@@ -231,31 +404,19 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return
_uiState.value =
_uiState.value.copy(
progress = fraction.coerceIn(0f, 1f),
)
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO,
)
.setState(
if (_uiState.value.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
(_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(),
1f,
)
.build(),
)
refreshForegroundNotification()
val p = player ?: return
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
p.seekTo(positionMs)
updateProgressFromPlayer()
}
/**
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
* May be called from any thread per Media3 [Player] contract.
*/
fun getPlaybackPositionMs(): Long {
return player?.currentPosition ?: 0L
}
private fun refreshForegroundNotification() {
@@ -332,6 +493,8 @@ class PlaybackService : Service() {
}
override fun onDestroy() {
positionPollJob?.cancel()
releasePlayer()
mediaSession.run {
isActive = false
release()
@@ -345,6 +508,9 @@ class PlaybackService : Service() {
val progress: Float,
val durationSeconds: Int,
val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
val queueSize: Int,
) {
companion object {
fun initial() =
@@ -354,6 +520,9 @@ class PlaybackService : Service() {
progress = 0f,
durationSeconds = 180,
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
queueSize = 0,
)
}
}
@@ -363,9 +532,14 @@ class PlaybackService : Service() {
val title: String,
val artist: String,
val localUri: Uri?,
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
)
companion object {
private const val UPDATE_INTERVAL_MS = 250L
private const val DEFAULT_DURATION_HINT_MS = 180_000
const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"

View File

@@ -0,0 +1,23 @@
package at.lockstep.player.playback
/**
* Fired when annotation should flush beats for the track being left:
* user skipped, auto-advance after a track ended, or the last track finished.
*/
data class TrackBoundaryEvent(
val trackId: String,
val title: String,
val artist: String,
/** 0-based index in the current play queue when this track was current. */
val queueIndex: Int,
val queueSize: Int,
val reason: TrackBoundaryReason,
)
enum class TrackBoundaryReason {
/** Left this track for another (skip, previous, or middle track ended). */
ADVANCED_TO_OTHER_TRACK,
/** Playback of the last track in the playlist completed. */
LAST_TRACK_FINISHED,
}