feat: refactor out MusicPlayerEngine
This commit is contained in:
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal file
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal file
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user