Compare commits
5 Commits
feature/sy
...
600c1e1407
| Author | SHA1 | Date | |
|---|---|---|---|
| 600c1e1407 | |||
| c41a758b49 | |||
| 166a2e1ffb | |||
| ab07086fcb | |||
| 096c676ce2 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ captures/
|
|||||||
*.apk
|
*.apk
|
||||||
*.ap_
|
*.ap_
|
||||||
*.aab
|
*.aab
|
||||||
|
# David
|
||||||
|
app/src/main/jniLibs
|
||||||
|
|||||||
14
BUGS.md
14
BUGS.md
@@ -1,5 +1,19 @@
|
|||||||
# Bugs
|
# Bugs
|
||||||
|
|
||||||
|
- There is a bug where re-opening the app (closing it through swiping away on the android app screen, then re-opening it) will display a splash screen with the app icon large and centered, and nothing happens.
|
||||||
|
|
||||||
|
- repro:
|
||||||
|
- open "Run 8" playlist
|
||||||
|
- skip a few songs forward/backward
|
||||||
|
- swipe away the app in Android
|
||||||
|
- (LibPasada.stop() - PlaybackEngine::stop() - musicFeed->join() hangs)
|
||||||
|
- open app again: warm restart by Android
|
||||||
|
- splash screen shows app icon, hangs there
|
||||||
|
|
||||||
|
- MP3File::~MP3File() must call mp3file_delete()
|
||||||
|
|
||||||
|
O> (fixed on 2026-06-11) scrubber was returning 2x the time progress from PasadaMusicPlayerEngine / liblockstep
|
||||||
|
|
||||||
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||||
|
|
||||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ class LockstepViewModel(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val feedAccelToPasada: StateFlow<Boolean> =
|
||||||
|
prefs.feedAccelToPasada.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
fun setAnnotationMode(enabled: Boolean) {
|
fun setAnnotationMode(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setAnnotationMode(enabled)
|
prefs.setAnnotationMode(enabled)
|
||||||
@@ -93,6 +100,12 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFeedAccelToPasada(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setFeedAccelToPasada(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val context get() = getApplication<Application>()
|
private val context get() = getApplication<Application>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -562,13 +575,34 @@ class LockstepViewModel(
|
|||||||
app.playlistRepository.syncPlaylistDetail(playlistId)
|
app.playlistRepository.syncPlaylistDetail(playlistId)
|
||||||
null
|
null
|
||||||
} catch (e: LockstepApiException) {
|
} catch (e: LockstepApiException) {
|
||||||
e.message ?: "Load failed"
|
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.message ?: "Load failed"
|
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist detail fetch is best-effort when opening from the library. Unauthorized responses mean
|
||||||
|
* the token is stale; cached jukebox rows (if any) are still valid for pairing and playback.
|
||||||
|
*/
|
||||||
|
private fun syncPlaylistDetailErrorForLibraryOpen(playlistId: String, e: Exception): String? {
|
||||||
|
if (isPlaylistDetailHttp401(e)) {
|
||||||
|
Log.w(TAG, "syncPlaylistDetail unauthorized for $playlistId, opening from jukebox cache", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) {
|
||||||
|
Log.w(TAG, "syncPlaylistDetail failed for $playlistId, using cached tracks", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return e.message ?: "Load failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPlaylistDetailHttp401(e: Exception): Boolean {
|
||||||
|
val msg = e.message ?: return false
|
||||||
|
return msg.contains("HTTP 401", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
fun completeOnboarding() {
|
fun completeOnboarding() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setOnboardingComplete(true)
|
prefs.setOnboardingComplete(true)
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ class UserPreferencesRepository(
|
|||||||
prefs[KEY_COLLECT_RUN_DATA] == true
|
prefs[KEY_COLLECT_RUN_DATA] == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When true, accelerometer samples are forwarded to libpasada during playback (not while annotating). */
|
||||||
|
val feedAccelToPasada: Flow<Boolean> =
|
||||||
|
dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_FEED_ACCEL_TO_PASADA] != false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setOnboardingComplete(done: Boolean) {
|
suspend fun setOnboardingComplete(done: Boolean) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[KEY_ONBOARDING_COMPLETE] = done
|
prefs[KEY_ONBOARDING_COMPLETE] = done
|
||||||
@@ -67,10 +73,17 @@ class UserPreferencesRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setFeedAccelToPasada(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_FEED_ACCEL_TO_PASADA] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
|
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
|
||||||
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
|
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
|
||||||
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
|
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
|
||||||
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
|
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
|
||||||
|
private val KEY_FEED_ACCEL_TO_PASADA = booleanPreferencesKey("feed_accel_to_pasada")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public final class LibPasada {
|
|||||||
if (loaded) {
|
if (loaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
System.loadLibrary("pasada");
|
System.loadLibrary("lockstep-native");
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -72,4 +78,7 @@ public final class LibPasada {
|
|||||||
|
|
||||||
/** Register listener for async events raised from the audio/native thread. */
|
/** Register listener for async events raised from the audio/native thread. */
|
||||||
public static native void setPlaybackListener(PasadaPlaybackListener listener);
|
public static native void setPlaybackListener(PasadaPlaybackListener listener);
|
||||||
|
|
||||||
|
/** native version string */
|
||||||
|
public static native String getVersion();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public interface PasadaPlaybackListener {
|
|||||||
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
|
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
|
||||||
void onTrackFinished();
|
void onTrackFinished();
|
||||||
|
|
||||||
|
void onTrackClosed(int fd);
|
||||||
|
|
||||||
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
|
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
|
||||||
void onError(int errorCode, String message);
|
void onError(int errorCode, String message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +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.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
|
||||||
@@ -47,9 +50,18 @@ class PlaybackService : Service() {
|
|||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
private var engine: MusicPlayerEngine? = null
|
private var engine: MusicPlayerEngine? = null
|
||||||
|
private var accelFeeder: PasadaAccelFeeder? = null
|
||||||
private var positionPollJob: Job? = null
|
private var positionPollJob: Job? = null
|
||||||
private var positionCachePollJob: Job? = null
|
private var positionCachePollJob: Job? = null
|
||||||
|
|
||||||
|
/** User preference: forward accelerometer samples to libpasada. */
|
||||||
|
@Volatile
|
||||||
|
private var feedAccelEnabled = true
|
||||||
|
|
||||||
|
/** True while the beat annotation screen is visible. */
|
||||||
|
@Volatile
|
||||||
|
private var isAnnotating = false
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
|
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
|
||||||
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -204,15 +216,50 @@ class PlaybackService : Service() {
|
|||||||
engine?.let {
|
engine?.let {
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
return ExoPlayerMusicPlayerEngine(this)
|
//return ExoPlayerMusicPlayerEngine(this)
|
||||||
|
return PasadaMusicPlayerEngine(this)
|
||||||
.also {
|
.also {
|
||||||
it.setListener(engineListener)
|
it.setListener(engineListener)
|
||||||
it.initSession()
|
it.initSession()
|
||||||
engine = it
|
engine = it
|
||||||
|
refreshAccelFeeding()
|
||||||
startPositionPolling()
|
startPositionPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called when the user toggles "Adapt playback to movement" in Settings. */
|
||||||
|
fun setFeedAccelEnabled(enabled: Boolean) {
|
||||||
|
feedAccelEnabled = enabled
|
||||||
|
refreshAccelFeeding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when entering or leaving the beat annotation screen. */
|
||||||
|
fun setAnnotating(annotating: Boolean) {
|
||||||
|
isAnnotating = annotating
|
||||||
|
refreshAccelFeeding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAccelFeeding() {
|
||||||
|
if (engine == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (feedAccelEnabled && !isAnnotating) {
|
||||||
|
startAccelFeeding()
|
||||||
|
} else {
|
||||||
|
stopAccelFeeding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAccelFeeding() {
|
||||||
|
val feeder =
|
||||||
|
accelFeeder ?: PasadaAccelFeeder(this).also { accelFeeder = it }
|
||||||
|
feeder.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopAccelFeeding() {
|
||||||
|
accelFeeder?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPositionPolling() {
|
private fun startPositionPolling() {
|
||||||
positionPollJob?.cancel()
|
positionPollJob?.cancel()
|
||||||
positionPollJob =
|
positionPollJob =
|
||||||
@@ -239,6 +286,7 @@ class PlaybackService : Service() {
|
|||||||
positionPollJob = null
|
positionPollJob = null
|
||||||
positionCachePollJob?.cancel()
|
positionCachePollJob?.cancel()
|
||||||
positionCachePollJob = null
|
positionCachePollJob = null
|
||||||
|
stopAccelFeeding()
|
||||||
engine?.setListener(null)
|
engine?.setListener(null)
|
||||||
engine?.releaseSession()
|
engine?.releaseSession()
|
||||||
engine = null
|
engine = null
|
||||||
@@ -289,32 +337,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 durationSec =
|
val beatTimesSec =
|
||||||
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
|
withContext(Dispatchers.IO) {
|
||||||
?: (item.durationMsHint / 1000).coerceAtLeast(1)
|
loadBeatTimesSecForTrack(item.id)
|
||||||
_uiState.value =
|
}
|
||||||
PlaybackUiState(
|
withContext(Dispatchers.Main) {
|
||||||
title = item.title,
|
val current = queue.getOrNull(index) ?: return@withContext
|
||||||
artist = item.artist,
|
if (current.id != item.id) {
|
||||||
progress = 0f,
|
return@withContext
|
||||||
durationSeconds = durationSec,
|
}
|
||||||
isPlaying = _uiState.value.isPlaying,
|
applyCurrentMediaItem(current, beatTimesSec)
|
||||||
currentTrackId = item.id,
|
val durationSec =
|
||||||
currentQueueIndex = index,
|
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
|
||||||
currentPlaylistPosition = item.playlistPosition,
|
?: (current.durationMsHint / 1000).coerceAtLeast(1)
|
||||||
queueSize = queue.size,
|
_uiState.value =
|
||||||
)
|
PlaybackUiState(
|
||||||
updateProgressFromEngine()
|
title = current.title,
|
||||||
updateSessionMetadata(item, durationSec)
|
artist = current.artist,
|
||||||
updatePlaybackStateFromEngine()
|
progress = 0f,
|
||||||
refreshForegroundNotification()
|
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 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 {
|
||||||
@@ -538,6 +613,8 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
stopPlaybackAndTeardown()
|
stopPlaybackAndTeardown()
|
||||||
|
accelFeeder?.release()
|
||||||
|
accelFeeder = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.net.Uri
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.util.Log
|
||||||
import at.lockstep.player.pasada.LibPasada
|
import at.lockstep.player.pasada.LibPasada
|
||||||
import at.lockstep.player.pasada.PasadaPlaybackListener
|
import at.lockstep.player.pasada.PasadaPlaybackListener
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -26,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
|
||||||
@@ -35,12 +37,18 @@ class PasadaMusicPlayerEngine(
|
|||||||
private val nativeListener =
|
private val nativeListener =
|
||||||
object : PasadaPlaybackListener {
|
object : PasadaPlaybackListener {
|
||||||
override fun onTrackFinished() {
|
override fun onTrackFinished() {
|
||||||
|
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
|
||||||
pendingStart = false
|
pendingStart = false
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
listener?.onPlaybackEnded()
|
listener?.onPlaybackEnded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTrackClosed(fd: Int) {
|
||||||
|
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
|
||||||
|
// we handle parcel close() separately
|
||||||
|
}
|
||||||
|
|
||||||
override fun onError(
|
override fun onError(
|
||||||
errorCode: Int,
|
errorCode: Int,
|
||||||
message: String,
|
message: String,
|
||||||
@@ -51,20 +59,29 @@ class PasadaMusicPlayerEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val TAG = "PasadaMusicPlayerEngine"
|
||||||
|
|
||||||
override fun initSession() {
|
override fun initSession() {
|
||||||
if (sessionInitialized) {
|
if (sessionInitialized) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "LibPasada.loadNative() ...")
|
||||||
LibPasada.loadNative()
|
LibPasada.loadNative()
|
||||||
LibPasada.setPlaybackListener(nativeListener)
|
LibPasada.setPlaybackListener(nativeListener)
|
||||||
|
Log.i(TAG, "LibPasada.init() ...")
|
||||||
LibPasada.init()
|
LibPasada.init()
|
||||||
|
Log.i(TAG, "LibPasada.init() done.")
|
||||||
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()
|
||||||
@@ -119,8 +136,11 @@ class PasadaMusicPlayerEngine(
|
|||||||
override fun releaseSession() {
|
override fun releaseSession() {
|
||||||
closeOpenTrack()
|
closeOpenTrack()
|
||||||
if (sessionInitialized) {
|
if (sessionInitialized) {
|
||||||
LibPasada.setPlaybackListener(null)
|
Log.i(TAG, "LibPasada.stop() ...")
|
||||||
LibPasada.stop()
|
LibPasada.stop()
|
||||||
|
Log.i(TAG, "LibPasada.stop() done.")
|
||||||
|
LibPasada.setPlaybackListener(null)
|
||||||
|
Log.i(TAG, "LibPasada.setPlaybackListener(null) done.")
|
||||||
sessionInitialized = false
|
sessionInitialized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +151,14 @@ class PasadaMusicPlayerEngine(
|
|||||||
|
|
||||||
private fun startPendingTrack() {
|
private fun startPendingTrack() {
|
||||||
val fd = trackFd ?: return
|
val fd = trackFd ?: return
|
||||||
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
|
pendingStart = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +192,7 @@ class PasadaMusicPlayerEngine(
|
|||||||
trackFd = null
|
trackFd = null
|
||||||
trackOffset = 0L
|
trackOffset = 0L
|
||||||
trackLength = -1L
|
trackLength = -1L
|
||||||
|
pendingBeatTimesSec = null
|
||||||
pendingStart = false
|
pendingStart = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -85,6 +86,13 @@ fun AnnotationRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(playback) {
|
||||||
|
playback?.setAnnotating(true)
|
||||||
|
onDispose {
|
||||||
|
playback?.setAnnotating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val beatTimesMs = remember { mutableStateListOf<Long>() }
|
val beatTimesMs = remember { mutableStateListOf<Long>() }
|
||||||
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.os.IBinder
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -34,8 +35,13 @@ fun LockstepAppNavHost(
|
|||||||
viewModel: LockstepViewModel,
|
viewModel: LockstepViewModel,
|
||||||
) {
|
) {
|
||||||
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||||
|
val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle()
|
||||||
var playback by remember { mutableStateOf<PlaybackService?>(null) }
|
var playback by remember { mutableStateOf<PlaybackService?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(feedAccelToPasada, playback) {
|
||||||
|
playback?.setFeedAccelEnabled(feedAccelToPasada)
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val connection =
|
val connection =
|
||||||
object : ServiceConnection {
|
object : ServiceConnection {
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(playback) {
|
||||||
|
playback?.setAnnotating(false)
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(collectRunData, playback) {
|
LaunchedEffect(collectRunData, playback) {
|
||||||
if (!collectRunData) {
|
if (!collectRunData) {
|
||||||
collector.setCollectingEnabled(false)
|
collector.setCollectingEnabled(false)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ fun SettingsScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||||
|
val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -83,6 +84,30 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { viewModel.setAnnotationMode(it) },
|
onCheckedChange = { viewModel.setAnnotationMode(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.settings_feed_accel_to_pasada),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.settings_feed_accel_to_pasada_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = feedAccelToPasada,
|
||||||
|
onCheckedChange = { viewModel.setFeedAccelToPasada(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
64
app/src/main/java/com/rex/logger/NdkLogger.java
Normal file
64
app/src/main/java/com/rex/logger/NdkLogger.java
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||||
|
* contributor license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright ownership.
|
||||||
|
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||||
|
* (the "License"); you may not use this file except in compliance with
|
||||||
|
* the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.rex.logger;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
//import proguard.annotation.KeepName;
|
||||||
|
|
||||||
|
//@KeepName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather native log into logback, for easy control save log into SDCard
|
||||||
|
*/
|
||||||
|
public class NdkLogger {
|
||||||
|
private static final String TAG = "NDK";
|
||||||
|
|
||||||
|
//@KeepName
|
||||||
|
public static void logWrite(int level, String message) {
|
||||||
|
//sLogger.trace("level:{} message:{}", level, message);
|
||||||
|
switch (level) {
|
||||||
|
case Log.VERBOSE:
|
||||||
|
Log.v(TAG, message);
|
||||||
|
break;
|
||||||
|
case Log.DEBUG:
|
||||||
|
Log.d(TAG, message);
|
||||||
|
break;
|
||||||
|
case Log.INFO:
|
||||||
|
Log.i(TAG, message);
|
||||||
|
break;
|
||||||
|
case Log.WARN:
|
||||||
|
Log.w(TAG, message);
|
||||||
|
break;
|
||||||
|
case Log.ERROR:
|
||||||
|
Log.e(TAG, message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
System.loadLibrary("ndk-logger");
|
||||||
|
} catch (UnsatisfiedLinkError ex) {
|
||||||
|
Log.e(TAG, "Failed to load library.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getABI() { return native_getABI(); }
|
||||||
|
private static native String native_getABI();
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
|
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
|
||||||
<string name="settings_collect_run_data">Collect run data</string>
|
<string name="settings_collect_run_data">Collect run data</string>
|
||||||
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder.</string>
|
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder.</string>
|
||||||
|
<string name="settings_feed_accel_to_pasada">Adapt playback to movement</string>
|
||||||
|
<string name="settings_feed_accel_to_pasada_help">When enabled, accelerometer data is sent to libpasada during Now playing so tempo can follow your pace. Disabled automatically on the beat annotation screen.</string>
|
||||||
<string name="annotation_title">Beat annotation</string>
|
<string name="annotation_title">Beat annotation</string>
|
||||||
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
|
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
|
||||||
<string name="annotation_tap_area_label">Tap here on the beat</string>
|
<string name="annotation_tap_area_label">Tap here on the beat</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user