feat: refactor out MusicPlayerEngine

This commit is contained in:
2026-05-24 10:56:52 +02:00
parent eb53d1e0ad
commit 183efd343e
7 changed files with 488 additions and 75 deletions

View File

@@ -13,12 +13,11 @@ 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 at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -36,10 +35,9 @@ 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.
* Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* current media item).
*/
class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -48,7 +46,7 @@ class PlaybackService : Service() {
private val binder = LocalBinder()
private var player: ExoPlayer? = null
private var engine: MusicPlayerEngine? = null
private var positionPollJob: Job? = null
private var positionCachePollJob: Job? = null
@@ -65,32 +63,37 @@ class PlaybackService : Service() {
private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0
/** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
@Volatile
private var cachedPlaybackPositionMs: Long = 0L
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 val engineListener =
object : MusicPlayerEngine.Listener {
override fun onPlaybackEnded() {
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)
}
}
override fun onError(
errorCode: Int,
message: String,
) {
setPlaying(false)
}
}
@@ -142,9 +145,8 @@ class PlaybackService : Service() {
}
override fun onSeekTo(pos: Long) {
val p = player ?: return
p.seekTo(pos)
updateProgressFromPlayer()
engine?.seekTo(pos)
updateProgressFromEngine()
}
},
)
@@ -196,15 +198,15 @@ class PlaybackService : Service() {
}
}
private fun ensurePlayer(): ExoPlayer {
player?.let {
private fun ensureEngine(): MusicPlayerEngine {
engine?.let {
return it
}
return ExoPlayer.Builder(this)
.build()
return ExoPlayerMusicPlayerEngine(this)
.also {
it.addListener(playerListener)
player = it
it.setListener(engineListener)
it.initSession()
engine = it
startPositionPolling()
}
}
@@ -215,8 +217,8 @@ class PlaybackService : Service() {
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
if (player != null && queue.isNotEmpty()) {
updateProgressFromPlayer()
if (engine != null && queue.isNotEmpty()) {
updateProgressFromEngine()
}
}
}
@@ -230,14 +232,14 @@ class PlaybackService : Service() {
}
}
private fun releasePlayer() {
private fun releaseEngine() {
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
player?.removeListener(playerListener)
player?.release()
player = null
engine?.setListener(null)
engine?.releaseSession()
engine = null
}
private fun startPlaylist(pid: String) {
@@ -267,14 +269,14 @@ class PlaybackService : Service() {
index = 0
if (queue.isEmpty()) {
withContext(Dispatchers.Main) {
releasePlayer()
releaseEngine()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return@launch
}
withContext(Dispatchers.Main) {
ensurePlayer()
ensureEngine()
setPlaying(true)
publishCurrentTrack()
refreshForegroundNotification()
@@ -286,7 +288,7 @@ class PlaybackService : Service() {
val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item)
val durationSec =
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value =
PlaybackUiState(
@@ -299,18 +301,21 @@ class PlaybackService : Service() {
currentQueueIndex = index,
queueSize = queue.size,
)
updateProgressFromPlayer()
updateProgressFromEngine()
updateSessionMetadata(item, durationSec)
updatePlaybackStateFromPlayer()
updatePlaybackStateFromEngine()
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
val e = ensureEngine()
e.prepareTrack(uri)
if (_uiState.value.isPlaying) {
e.play()
} else {
e.pause()
}
}
private fun updateSessionMetadata(
@@ -327,27 +332,26 @@ class PlaybackService : Service() {
}
private fun currentDurationMs(): Long {
val p = player
val fromPlayer = p?.duration?.takeIf { it > 0 }
if (fromPlayer != null) {
return fromPlayer
val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
if (fromEngine != null) {
return fromEngine
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun refreshCachedPlaybackPositionMs() {
val p = player ?: return
val e = engine ?: return
if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs)
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
}
private fun updateProgressFromPlayer() {
val p = player ?: return
private fun updateProgressFromEngine() {
val e = engine ?: return
refreshCachedPlaybackPositionMs()
if (queue.isEmpty()) {
return
@@ -364,12 +368,12 @@ class PlaybackService : Service() {
currentQueueIndex = index,
queueSize = queue.size,
)
updatePlaybackStateFromPlayer()
updatePlaybackStateFromEngine()
}
private fun updatePlaybackStateFromPlayer() {
val p = player
val positionMs = p?.currentPosition ?: 0L
private fun updatePlaybackStateFromEngine() {
val e = engine
val positionMs = e?.getCurrentPositionMs() ?: 0L
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
@@ -377,7 +381,7 @@ class PlaybackService : Service() {
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
val state =
if (_uiState.value.isPlaying && p?.isPlaying == true) {
if (_uiState.value.isPlaying && e?.isPlaying() == true) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
@@ -392,8 +396,12 @@ class PlaybackService : Service() {
private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing)
player?.playWhenReady = playing
updatePlaybackStateFromPlayer()
if (playing) {
engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
refreshForegroundNotification()
}
@@ -430,15 +438,15 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return
val p = player ?: return
val e = engine ?: return
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
p.seekTo(positionMs)
updateProgressFromPlayer()
e.seekTo(positionMs)
updateProgressFromEngine()
}
/**
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
* Milliseconds from the start of the current track — same timebase as the playback engine.
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
*/
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
@@ -519,7 +527,7 @@ class PlaybackService : Service() {
override fun onDestroy() {
positionPollJob?.cancel()
positionCachePollJob?.cancel()
releasePlayer()
releaseEngine()
mediaSession.run {
isActive = false
release()
@@ -557,7 +565,7 @@ class PlaybackService : Service() {
val title: String,
val artist: String,
val localUri: Uri?,
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
)