feat: pass beat times to native player

This commit is contained in:
2026-06-14 18:09:07 +02:00
parent 166a2e1ffb
commit c41a758b49
6 changed files with 102 additions and 28 deletions

View File

@@ -37,8 +37,14 @@ public final class LibPasada {
* @param fd open read FD (Java retains ownership; do not close until track changes) * @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 offset start offset within the FD (0 for whole file)
* @param length byte length from offset ({@code -1} if unknown / to EOF) * @param length byte length from offset ({@code -1} if unknown / to EOF)
* @param beatTimesSec beat times in seconds from track start, or {@code null} when unknown
*/ */
public static native void play(int fd, long offset, long length); public static native void play(int fd, long offset, long length, double[] beatTimesSec);
/** Same as {@link #play(int, long, long, double[])} with no beat annotation. */
public static void play(int fd, long offset, long length) {
play(fd, offset, length, null);
}
/** PLAYING → PAUSED (silent output, graph kept alive). */ /** PLAYING → PAUSED (silent output, graph kept alive). */
public static native void pause(); public static native void pause();

View File

@@ -16,9 +16,11 @@ import androidx.media.app.NotificationCompat.MediaStyle
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.data.db.FileMetadataEntity
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine import at.lockstep.player.playback.engine.MusicPlayerEngine
import at.lockstep.player.util.BeatAnnotationStorage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -291,32 +293,59 @@ class PlaybackService : Service() {
private fun publishCurrentTrack() { private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item) scope.launch {
val beatTimesSec =
withContext(Dispatchers.IO) {
loadBeatTimesSecForTrack(item.id)
}
withContext(Dispatchers.Main) {
val current = queue.getOrNull(index) ?: return@withContext
if (current.id != item.id) {
return@withContext
}
applyCurrentMediaItem(current, beatTimesSec)
val durationSec = val durationSec =
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt()) (engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1) ?: (current.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value = _uiState.value =
PlaybackUiState( PlaybackUiState(
title = item.title, title = current.title,
artist = item.artist, artist = current.artist,
progress = 0f, progress = 0f,
durationSeconds = durationSec, durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying, isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id, currentTrackId = current.id,
currentQueueIndex = index, currentQueueIndex = index,
currentPlaylistPosition = item.playlistPosition, currentPlaylistPosition = current.playlistPosition,
queueSize = queue.size, queueSize = queue.size,
) )
updateProgressFromEngine() updateProgressFromEngine()
updateSessionMetadata(item, durationSec) updateSessionMetadata(current, durationSec)
updatePlaybackStateFromEngine() updatePlaybackStateFromEngine()
refreshForegroundNotification() refreshForegroundNotification()
} }
}
}
private fun applyCurrentMediaItem(item: TrackQueueItem) { private suspend fun loadBeatTimesSecForTrack(trackId: String): DoubleArray? {
val metadata =
app.database.fileMetadataDao().findByTrackIdAndType(
trackId,
FileMetadataEntity.TYPE_BEATS,
) ?: return null
if (metadata.fileUri.isBlank()) {
return null
}
return BeatAnnotationStorage.readBeatTimesSec(app, metadata.fileUri)
}
private fun applyCurrentMediaItem(
item: TrackQueueItem,
beatTimesSec: DoubleArray?,
) {
val uri = item.localUri ?: return val uri = item.localUri ?: return
val e = ensureEngine() val e = ensureEngine()
e.prepareTrack(uri) e.prepareTrack(uri, beatTimesSec)
if (_uiState.value.isPlaying) { if (_uiState.value.isPlaying) {
e.play() e.play()
} else { } else {

View File

@@ -33,7 +33,10 @@ class ExoPlayerMusicPlayerEngine(
.also { it.addListener(playerListener) } .also { it.addListener(playerListener) }
} }
override fun prepareTrack(uri: Uri) { override fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray?,
) {
val p = requirePlayer() val p = requirePlayer()
p.setMediaItem(MediaItem.fromUri(uri)) p.setMediaItem(MediaItem.fromUri(uri))
p.prepare() p.prepare()

View File

@@ -14,7 +14,13 @@ interface MusicPlayerEngine {
* Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is * Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is
* called (or immediately if [playWhenReady] is set via [play]). * called (or immediately if [playWhenReady] is set via [play]).
*/ */
fun prepareTrack(uri: Uri) /**
* @param beatTimesSec optional beat times in seconds from [TYPE_BEATS] metadata; passed to libpasada on play.
*/
fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray? = null,
)
fun play() fun play()

View File

@@ -27,6 +27,7 @@ class PasadaMusicPlayerEngine(
private var trackFd: Int? = null private var trackFd: Int? = null
private var trackOffset: Long = 0L private var trackOffset: Long = 0L
private var trackLength: Long = -1L private var trackLength: Long = -1L
private var pendingBeatTimesSec: DoubleArray? = null
/** FD is open but [LibPasada.play] has not been called yet for this track. */ /** FD is open but [LibPasada.play] has not been called yet for this track. */
private var pendingStart = false private var pendingStart = false
@@ -73,10 +74,14 @@ class PasadaMusicPlayerEngine(
sessionInitialized = true sessionInitialized = true
} }
override fun prepareTrack(uri: Uri) { override fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray?,
) {
closeOpenTrack() closeOpenTrack()
try { try {
openTrack(uri) openTrack(uri)
pendingBeatTimesSec = beatTimesSec
pendingStart = true pendingStart = true
} catch (e: IOException) { } catch (e: IOException) {
closeOpenTrack() closeOpenTrack()
@@ -146,9 +151,14 @@ class PasadaMusicPlayerEngine(
private fun startPendingTrack() { private fun startPendingTrack() {
val fd = trackFd ?: return val fd = trackFd ?: return
Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...") val beats = pendingBeatTimesSec
LibPasada.play(fd, trackOffset, trackLength) Log.i(
TAG,
"LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength, beats=${beats?.size ?: 0}) ...",
)
LibPasada.play(fd, trackOffset, trackLength, beats)
Log.i(TAG, "LibPasada.play() done.") Log.i(TAG, "LibPasada.play() done.")
pendingBeatTimesSec = null
pendingStart = false pendingStart = false
} }
@@ -182,6 +192,7 @@ class PasadaMusicPlayerEngine(
trackFd = null trackFd = null
trackOffset = 0L trackOffset = 0L
trackLength = -1L trackLength = -1L
pendingBeatTimesSec = null
pendingStart = false pendingStart = false
} }

View File

@@ -62,6 +62,25 @@ object BeatAnnotationStorage {
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString) return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
} }
/** Reads [beatTimesSec] from a beats or annotation JSON file; {@code null} if missing or unreadable. */
fun readBeatTimesSec(
context: Context,
fileUri: String,
): DoubleArray? {
if (fileUri.isBlank()) {
return null
}
return try {
context.contentResolver.openInputStream(Uri.parse(fileUri))?.use { stream ->
val json = JSONObject(stream.bufferedReader().readText())
val arr = json.optJSONArray("beatTimesSec") ?: return null
DoubleArray(arr.length()) { i -> arr.getDouble(i) }
}
} catch (_: Exception) {
null
}
}
fun buildAnnotationJson( fun buildAnnotationJson(
contentId: String, contentId: String,
title: String, title: String,