diff --git a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java index 478c092..f0e4c54 100644 --- a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java +++ b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java @@ -37,8 +37,14 @@ public final class LibPasada { * @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) + * @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). */ public static native void pause(); 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 89171a5..2ff0b12 100644 --- a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt +++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt @@ -16,9 +16,11 @@ import androidx.media.app.NotificationCompat.MediaStyle import at.lockstep.player.LockstepApplication import at.lockstep.player.MainActivity 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.PasadaMusicPlayerEngine import at.lockstep.player.playback.engine.MusicPlayerEngine +import at.lockstep.player.util.BeatAnnotationStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -291,32 +293,59 @@ class PlaybackService : Service() { private fun publishCurrentTrack() { val item = queue.getOrNull(index) ?: return - applyCurrentMediaItem(item) - val durationSec = - (engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt()) - ?: (item.durationMsHint / 1000).coerceAtLeast(1) - _uiState.value = - PlaybackUiState( - title = item.title, - artist = item.artist, - progress = 0f, - durationSeconds = durationSec, - isPlaying = _uiState.value.isPlaying, - currentTrackId = item.id, - currentQueueIndex = index, - currentPlaylistPosition = item.playlistPosition, - queueSize = queue.size, - ) - updateProgressFromEngine() - updateSessionMetadata(item, durationSec) - updatePlaybackStateFromEngine() - refreshForegroundNotification() + 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 = + (engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt()) + ?: (current.durationMsHint / 1000).coerceAtLeast(1) + _uiState.value = + PlaybackUiState( + title = current.title, + artist = current.artist, + progress = 0f, + durationSeconds = durationSec, + isPlaying = _uiState.value.isPlaying, + currentTrackId = current.id, + currentQueueIndex = index, + currentPlaylistPosition = current.playlistPosition, + queueSize = queue.size, + ) + updateProgressFromEngine() + updateSessionMetadata(current, durationSec) + updatePlaybackStateFromEngine() + 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 e = ensureEngine() - e.prepareTrack(uri) + e.prepareTrack(uri, beatTimesSec) if (_uiState.value.isPlaying) { e.play() } else { 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 index 155a0d5..b6bbdb2 100644 --- a/app/src/main/java/at/lockstep/player/playback/engine/ExoPlayerMusicPlayerEngine.kt +++ b/app/src/main/java/at/lockstep/player/playback/engine/ExoPlayerMusicPlayerEngine.kt @@ -33,7 +33,10 @@ class ExoPlayerMusicPlayerEngine( .also { it.addListener(playerListener) } } - override fun prepareTrack(uri: Uri) { + override fun prepareTrack( + uri: Uri, + beatTimesSec: DoubleArray?, + ) { val p = requirePlayer() p.setMediaItem(MediaItem.fromUri(uri)) p.prepare() 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 index a59abf9..438257e 100644 --- a/app/src/main/java/at/lockstep/player/playback/engine/MusicPlayerEngine.kt +++ b/app/src/main/java/at/lockstep/player/playback/engine/MusicPlayerEngine.kt @@ -14,7 +14,13 @@ interface MusicPlayerEngine { * 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) + /** + * @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() 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 index a38ea67..22cf708 100644 --- a/app/src/main/java/at/lockstep/player/playback/engine/PasadaMusicPlayerEngine.kt +++ b/app/src/main/java/at/lockstep/player/playback/engine/PasadaMusicPlayerEngine.kt @@ -27,6 +27,7 @@ class PasadaMusicPlayerEngine( private var trackFd: Int? = null private var trackOffset: Long = 0L private var trackLength: Long = -1L + private var pendingBeatTimesSec: DoubleArray? = null /** FD is open but [LibPasada.play] has not been called yet for this track. */ private var pendingStart = false @@ -73,10 +74,14 @@ class PasadaMusicPlayerEngine( sessionInitialized = true } - override fun prepareTrack(uri: Uri) { + override fun prepareTrack( + uri: Uri, + beatTimesSec: DoubleArray?, + ) { closeOpenTrack() try { openTrack(uri) + pendingBeatTimesSec = beatTimesSec pendingStart = true } catch (e: IOException) { closeOpenTrack() @@ -146,9 +151,14 @@ class PasadaMusicPlayerEngine( private fun startPendingTrack() { val fd = trackFd ?: return - Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...") - LibPasada.play(fd, trackOffset, trackLength) + val beats = pendingBeatTimesSec + 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.") + pendingBeatTimesSec = null pendingStart = false } @@ -182,6 +192,7 @@ class PasadaMusicPlayerEngine( trackFd = null trackOffset = 0L trackLength = -1L + pendingBeatTimesSec = null pendingStart = false } diff --git a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt index 96282bc..4d9247b 100644 --- a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt @@ -62,6 +62,25 @@ object BeatAnnotationStorage { 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( contentId: String, title: String,