diff --git a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java new file mode 100644 index 0000000..9ff393b --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java @@ -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. + * + *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED + * + *

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); +} diff --git a/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java b/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java new file mode 100644 index 0000000..48e9009 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java @@ -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); +} diff --git a/app/src/main/java/at/lockstep/player/pasada/PasadaState.java b/app/src/main/java/at/lockstep/player/pasada/PasadaState.java new file mode 100644 index 0000000..80e8dd8 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/PasadaState.java @@ -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); + } +} diff --git a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt index f6372d4..59f234b 100644 --- a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt +++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt @@ -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 = 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, ) diff --git a/app/src/main/java/at/lockstep/player/playback/engine/ExoPlayerMusicPlayerEngine.kt b/app/src/main/java/at/lockstep/player/playback/engine/ExoPlayerMusicPlayerEngine.kt new file mode 100644 index 0000000..155a0d5 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/playback/engine/ExoPlayerMusicPlayerEngine.kt @@ -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") +} diff --git a/app/src/main/java/at/lockstep/player/playback/engine/MusicPlayerEngine.kt b/app/src/main/java/at/lockstep/player/playback/engine/MusicPlayerEngine.kt new file mode 100644 index 0000000..a59abf9 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/playback/engine/MusicPlayerEngine.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/at/lockstep/player/playback/engine/PasadaMusicPlayerEngine.kt b/app/src/main/java/at/lockstep/player/playback/engine/PasadaMusicPlayerEngine.kt new file mode 100644 index 0000000..fab66f9 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/playback/engine/PasadaMusicPlayerEngine.kt @@ -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 + } +}