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

@@ -0,0 +1,75 @@
package at.lockstep.player.pasada;
/**
* JNI entry point for libpasada. Used by {@link at.lockstep.player.playback.engine.PasadaMusicPlayerEngine};
* {@link at.lockstep.player.playback.PlaybackService} still defaults to
* {@link at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine} until the native library is ready.
*
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*
* <p>Call {@link #loadNative()} once before any other method once the {@code pasada} shared
* library is added via CMake (step 3).
*/
public final class LibPasada {
private static boolean loaded;
private LibPasada() {}
/** Loads {@code libpasada.so}. Safe to call multiple times. */
public static synchronized void loadNative() {
if (loaded) {
return;
}
System.loadLibrary("pasada");
loaded = true;
}
/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */
public static native void init();
/** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */
public static native void feedAccel(float x, float y, float z, long timestampNanos);
/**
* Open MP3 from an already-open file descriptor and begin adaptive playback.
*
* @param fd open read FD (Java retains ownership; do not close until track changes)
* @param offset start offset within the FD (0 for whole file)
* @param length byte length from offset ({@code -1} if unknown / to EOF)
*/
public static native void play(int fd, long offset, long length);
/** PLAYING → PAUSED (silent output, graph kept alive). */
public static native void pause();
/** PAUSED → PLAYING (same track, same decode position, same FD). */
public static native void resume();
/** Tear down Oboe for this run segment → STOPPED. */
public static native void stop();
/**
* Milliseconds from the start of the current track — same timebase as ExoPlayer
* {@code getCurrentPosition()}. Safe to poll from background threads.
*/
public static native long getCurrentPositionMs();
/** Track duration in ms, or {@code 0} if not yet known. */
public static native long getDurationMs();
/** Whether adapted audio is actively being output (not paused, not finished). */
public static native boolean isPlaying();
/** Current native state; see {@link PasadaState}. */
public static native int getState();
/** Seek within the current track. */
public static native void seekTo(long positionMs);
/** Runtime metrics / last error string for logging and debug UI. */
public static native String getDiagnostics();
/** Register listener for async events raised from the audio/native thread. */
public static native void setPlaybackListener(PasadaPlaybackListener listener);
}

View File

@@ -0,0 +1,14 @@
package at.lockstep.player.pasada;
/**
* Callbacks invoked from native (Oboe or internal worker thread).
* Implementations must post to the main thread if they touch UI or service state.
*/
public interface PasadaPlaybackListener {
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished();
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message);
}

View File

@@ -0,0 +1,26 @@
package at.lockstep.player.pasada;
/** Mirrors the libpasada state machine documented in DESIGN.md. */
public enum PasadaState {
LOADED(0),
INITIALIZED(1),
PLAYING(2),
PAUSED(3),
FINISHED(4),
STOPPED(5);
public final int code;
PasadaState(int code) {
this.code = code;
}
public static PasadaState fromCode(int code) {
for (PasadaState state : values()) {
if (state.code == code) {
return state;
}
}
throw new IllegalArgumentException("Unknown PasadaState code: " + code);
}
}

View File

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

View File

@@ -0,0 +1,72 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
/** [MusicPlayerEngine] backed by Media3 [ExoPlayer] until libpasada is wired in. */
class ExoPlayerMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private var player: ExoPlayer? = null
private var listener: MusicPlayerEngine.Listener? = null
private val playerListener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
listener?.onPlaybackEnded()
}
}
}
override fun initSession() {
if (player != null) {
return
}
player =
ExoPlayer.Builder(appContext)
.build()
.also { it.addListener(playerListener) }
}
override fun prepareTrack(uri: Uri) {
val p = requirePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
}
override fun play() {
requirePlayer().playWhenReady = true
}
override fun pause() {
player?.playWhenReady = false
}
override fun seekTo(positionMs: Long) {
requirePlayer().seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long = player?.currentPosition ?: 0L
override fun getDurationMs(): Long = player?.duration?.takeIf { it > 0 } ?: 0L
override fun isPlaying(): Boolean = player?.isPlaying == true
override fun releaseSession() {
player?.removeListener(playerListener)
player?.release()
player = null
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun requirePlayer(): ExoPlayer =
player ?: throw IllegalStateException("Call initSession() before using the player")
}

View File

@@ -0,0 +1,44 @@
package at.lockstep.player.playback.engine
import android.net.Uri
/**
* Playback engine used by [at.lockstep.player.playback.PlaybackService].
* ExoPlayer today; libpasada via JNI later.
*/
interface MusicPlayerEngine {
/** Arm the engine for a new playlist/run session. */
fun initSession()
/**
* Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is
* called (or immediately if [playWhenReady] is set via [play]).
*/
fun prepareTrack(uri: Uri)
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun getCurrentPositionMs(): Long
fun getDurationMs(): Long
fun isPlaying(): Boolean
/** Release resources for this session. */
fun releaseSession()
fun setListener(listener: Listener?)
interface Listener {
fun onPlaybackEnded()
fun onError(
errorCode: Int,
message: String,
)
}
}

View File

@@ -0,0 +1,174 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException
/**
* [MusicPlayerEngine] backed by libpasada JNI. Opens local URIs into file descriptors for native
* decode; maps prepare/play/pause semantics onto [LibPasada.play] / [LibPasada.resume].
*/
class PasadaMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private var listener: MusicPlayerEngine.Listener? = null
private var openAsset: AssetFileDescriptor? = null
private var openParcel: ParcelFileDescriptor? = null
private var trackFd: Int? = null
private var trackOffset: Long = 0L
private var trackLength: Long = -1L
/** FD is open but [LibPasada.play] has not been called yet for this track. */
private var pendingStart = false
private var sessionInitialized = false
private val nativeListener =
object : PasadaPlaybackListener {
override fun onTrackFinished() {
pendingStart = false
mainHandler.post {
listener?.onPlaybackEnded()
}
}
override fun onError(
errorCode: Int,
message: String,
) {
mainHandler.post {
listener?.onError(errorCode, message)
}
}
}
override fun initSession() {
if (sessionInitialized) {
return
}
LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener)
LibPasada.init()
sessionInitialized = true
}
override fun prepareTrack(uri: Uri) {
closeOpenTrack()
try {
openTrack(uri)
pendingStart = true
} catch (e: IOException) {
closeOpenTrack()
listener?.onError(ERROR_OPEN_TRACK, e.message ?: "Failed to open $uri")
}
}
override fun play() {
if (pendingStart) {
startPendingTrack()
return
}
if (!sessionInitialized) {
return
}
if (!LibPasada.isPlaying()) {
LibPasada.resume()
}
}
override fun pause() {
if (pendingStart) {
return
}
LibPasada.pause()
}
override fun seekTo(positionMs: Long) {
if (pendingStart) {
return
}
LibPasada.seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getCurrentPositionMs()
}
override fun getDurationMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getDurationMs()
}
override fun isPlaying(): Boolean =
sessionInitialized && !pendingStart && LibPasada.isPlaying()
override fun releaseSession() {
closeOpenTrack()
if (sessionInitialized) {
LibPasada.setPlaybackListener(null)
LibPasada.stop()
sessionInitialized = false
}
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun startPendingTrack() {
val fd = trackFd ?: return
LibPasada.play(fd, trackOffset, trackLength)
pendingStart = false
}
@Throws(IOException::class)
private fun openTrack(uri: Uri) {
val resolver = appContext.contentResolver
resolver.openAssetFileDescriptor(uri, "r")?.let { afd ->
val pfd =
afd.parcelFileDescriptor
?: throw IOException("No ParcelFileDescriptor for $uri")
openAsset = afd
trackFd = pfd.fd
trackOffset = afd.startOffset
trackLength = if (afd.length >= 0) afd.length else -1L
return
}
val pfd =
resolver.openFileDescriptor(uri, "r")
?: throw IOException("Cannot open $uri")
openParcel = pfd
trackFd = pfd.fd
trackOffset = 0L
trackLength = -1L
}
private fun closeOpenTrack() {
openAsset?.close()
openAsset = null
openParcel?.close()
openParcel = null
trackFd = null
trackOffset = 0L
trackLength = -1L
pendingStart = false
}
private companion object {
const val ERROR_OPEN_TRACK = 1
}
}