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
+ }
+}