6 Commits

20 changed files with 426 additions and 458 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ captures/
*.apk *.apk
*.ap_ *.ap_
*.aab *.aab
# David
app/src/main/jniLibs

14
BUGS.md
View File

@@ -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

View File

@@ -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)

View File

@@ -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")
} }
} }

View File

@@ -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();
} }

View File

@@ -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);
} }

View File

@@ -0,0 +1,77 @@
package at.lockstep.player.playback
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import at.lockstep.player.pasada.LibPasada
/**
* Registers the accelerometer on a background [HandlerThread] and forwards samples to
* [LibPasada.feedAccel]. Owned by [PlaybackService] for the duration of a run session.
*/
class PasadaAccelFeeder(
context: Context,
) {
private val sensorManager = context.applicationContext.getSystemService(SensorManager::class.java)
private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private val handlerThread = HandlerThread("PasadaAccel").apply { start() }
private val handler = Handler(handlerThread.looper)
@Volatile
private var feedingEnabled = false
private var registered = false
private val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (!feedingEnabled || event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
return
}
LibPasada.feedAccel(
event.values[0],
event.values[1],
event.values[2],
event.timestamp,
)
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
fun start() {
if (registered || sensorManager == null || accelerometer == null) {
return
}
feedingEnabled = true
sensorManager.registerListener(
listener,
accelerometer,
SensorManager.SENSOR_DELAY_GAME,
0,
handler,
)
registered = true
}
fun stop() {
feedingEnabled = false
if (!registered || sensorManager == null) {
return
}
sensorManager.unregisterListener(listener)
registered = false
}
fun release() {
stop()
handlerThread.quitSafely()
}
}

View File

@@ -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()
} }

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

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

View File

@@ -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() }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

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,

View File

@@ -1,102 +0,0 @@
package at.lockstep.player.util
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketTimeoutException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
object NtpClient {
private const val NTP_HOST = "pool.ntp.org"
private const val NTP_PORT = 123
private const val NTP_PACKET_SIZE = 48
private const val TIMEOUT_MS = 5000
private const val NTP_EPOCH_OFFSET_SECONDS = 2208988800L
/**
* Queries [NTP_HOST] and returns how many seconds the system clock is ahead of the server.
* Positive = local clock is fast; negative = local clock is slow.
*/
@Throws(Exception::class)
fun clockOffsetSeconds(): Double {
val socket = DatagramSocket()
try {
socket.soTimeout = TIMEOUT_MS
val requestBuffer = ByteArray(NTP_PACKET_SIZE)
requestBuffer[0] = 0x1B // LI=0, VN=3, mode=client
val t1Millis = System.currentTimeMillis()
writeNtpTimestamp(requestBuffer, 40, t1Millis)
val address = InetAddress.getByName(NTP_HOST)
val requestPacket = DatagramPacket(requestBuffer, requestBuffer.size, address, NTP_PORT)
socket.send(requestPacket)
val responseBuffer = ByteArray(NTP_PACKET_SIZE)
val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size)
val errorRef = AtomicReference<Exception?>(null)
val receiveTimeRef = AtomicReference<Long?>(null)
val latch = CountDownLatch(1)
Thread {
try {
socket.receive(responsePacket)
receiveTimeRef.set(System.currentTimeMillis())
} catch (e: Exception) {
errorRef.set(e)
} finally {
latch.countDown()
}
}.start()
if (!latch.await((TIMEOUT_MS + 1000).toLong(), TimeUnit.MILLISECONDS)) {
throw SocketTimeoutException("NTP request timed out")
}
errorRef.get()?.let { throw it }
val t4Millis = receiveTimeRef.get()
?: throw IllegalStateException("NTP response received without timestamp")
val t1 = t1Millis / 1000.0
val t2 = ntpBytesToSeconds(responseBuffer, 32)
val t3 = ntpBytesToSeconds(responseBuffer, 40)
val t4 = t4Millis / 1000.0
return ((t1 - t2) + (t4 - t3)) / 2.0
} finally {
socket.close()
}
}
private fun ntpBytesToSeconds(buffer: ByteArray, offset: Int): Double {
val seconds = readUint32(buffer, offset)
val fraction = readUint32(buffer, offset + 4)
return (seconds - NTP_EPOCH_OFFSET_SECONDS) + fraction / 4294967296.0
}
private fun writeNtpTimestamp(buffer: ByteArray, offset: Int, millis: Long) {
val unixSeconds = millis / 1000.0
val ntpSeconds = (unixSeconds + NTP_EPOCH_OFFSET_SECONDS).toLong()
val fraction = ((unixSeconds - unixSeconds.toLong()) * 4294967296.0).toLong()
writeUint32(buffer, offset, ntpSeconds)
writeUint32(buffer, offset + 4, fraction)
}
private fun readUint32(buffer: ByteArray, offset: Int): Long =
((buffer[offset].toLong() and 0xFF) shl 24) or
((buffer[offset + 1].toLong() and 0xFF) shl 16) or
((buffer[offset + 2].toLong() and 0xFF) shl 8) or
(buffer[offset + 3].toLong() and 0xFF)
private fun writeUint32(buffer: ByteArray, offset: Int, value: Long) {
buffer[offset] = ((value shr 24) and 0xFF).toByte()
buffer[offset + 1] = ((value shr 16) and 0xFF).toByte()
buffer[offset + 2] = ((value shr 8) and 0xFF).toByte()
buffer[offset + 3] = (value and 0xFF).toByte()
}
}

View 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();
}

View File

@@ -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>

View File

@@ -1,324 +0,0 @@
---
name: Runbuddy Mode Plan
overview: "Add 1:1 RunBuddy sessions: leader creates a public session on the server, follower picks it from an in-app billboard, and both stay in sync via WebSocket events timestamped with NTP-corrected wall clock. Both devices use the same Spotify playlist and local MP3 pairings."
todos:
- id: server-schema-rest
content: Add runbuddy_sessions/participants tables + REST endpoints (create, billboard, join, end, leave) to lockstep-2-api
status: pending
- id: server-websocket
content: Add WebSocket room relay with token auth, snapshot persistence, and leader→follower fan-out
status: pending
- id: android-clients
content: Implement RunBuddyApiClient, RunBuddyWebSocketClient (OkHttp), RunBuddyClock wrapping NtpClient
status: pending
- id: sync-protocol
content: Define JSON event schema and follower drift correction math in RunBuddyCoordinator
status: pending
- id: playback-hooks
content: Extend PlaybackService with attachRunBuddy, applyRemoteTransport, leader emit hooks on transport changes
status: pending
- id: prejoin-validation
content: Follower pairing validation against session playlistId + pairedTrackCount before join
status: pending
- id: ui-flows
content: Add RunBuddy billboard screen, leader start/waiting UI, Now Playing role banner and follower control lockout
status: pending
- id: e2e-test
content: "Two-device test: create/join, play/pause/seek/skip/track-change, verify <50ms perceived drift while running"
status: pending
isProject: false
---
# RunBuddy Mode — Implementation Plan
## Scope (confirmed)
- **1 leader + 1 follower** per session (session locks when follower joins)
- **Same Spotify playlist + same local MP3 pairings** on both devices
- **Leader is source of truth** for playback transport and position
- **Position sync target:** millisecond-level via [`NtpClient.kt`](app/src/main/java/at/lockstep/player/util/NtpClient.kt) + timestamped realtime events
## Current baseline
| Layer | Today | Gap |
|-------|-------|-----|
| Playback | [`PlaybackService`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) owns queue, play/pause/seek/skip locally | No remote command ingress |
| Networking | REST only ([`MetadataSyncClient`](app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt), jukebox) | No sessions, no WebSocket |
| Server | Flask monolith [`lockstep-2-api/api.py`](lockstep-2-api/api.py) | No session model, no realtime |
| Clock | `NtpClient` exists but unused | Wire into session lifecycle |
```mermaid
sequenceDiagram
participant Leader as LeaderApp
participant Server as api.lockstep.at
participant Follower as FollowerApp
participant NTP as pool.ntp.org
Leader->>NTP: NtpClient (offset)
Leader->>Server: POST /runbuddy/sessions
Server-->>Leader: sessionId, joinCode, wsToken
Leader->>Server: WS connect (leader)
Follower->>Server: GET /runbuddy/sessions (billboard)
Follower->>Server: POST /runbuddy/sessions/{id}/join
Follower->>NTP: NtpClient (offset)
Follower->>Server: WS connect (follower)
Leader->>Server: transport + position events (correctedMs)
Server->>Follower: fan-out events
Follower->>Follower: apply to PlaybackService
```
---
## 1. Server — session model + billboard + WebSocket
Extend [`lockstep-2-api/api.py`](lockstep-2-api/api.py) (or split into `runbuddy.py` imported by `api.py` to keep the monolith manageable).
### SQLite tables
**`runbuddy_sessions`**
- `id` (UUID, PK)
- `host_spotify_user_id`
- `playlist_id`, `playlist_name`
- `paired_track_count` (leader-reported, for follower pre-check)
- `status` (`waiting` | `active` | `ended`)
- `created_at`, `expires_at` (TTL, e.g. 2h idle)
**`runbuddy_participants`**
- `session_id`, `role` (`leader` | `follower`)
- `spotify_user_id`, `joined_at`
- Unique constraint: one follower per session
**`runbuddy_ws_tokens`**
- Short-lived join token per participant (avoid putting Spotify bearer in WS query string long-term)
### REST endpoints (all `@require_auth` with existing Bearer Spotify token)
| Method | Route | Purpose |
|--------|-------|---------|
| `POST` | `/runbuddy/sessions` | Leader creates session. Body: `{ playlistId, playlistName, pairedTrackCount }`. Returns `{ sessionId, joinCode, wsUrl, wsToken, snapshot }`. |
| `GET` | `/runbuddy/sessions` | **Billboard**: list `status=waiting` public sessions (host display name, playlist name, paired count, created_at). |
| `GET` | `/runbuddy/sessions/{id}` | Session snapshot for late join / reconnect. |
| `POST` | `/runbuddy/sessions/{id}/join` | Follower joins (409 if full). Returns `{ wsUrl, wsToken, snapshot }`. |
| `POST` | `/runbuddy/sessions/{id}/end` | Leader ends session. |
| `POST` | `/runbuddy/sessions/{id}/leave` | Follower leaves. |
**Snapshot** (stored server-side, updated by leader over WS) includes current transport state:
```json
{
"playlistId": "...",
"playlistPosition": 3,
"trackId": "spotify:track:...",
"positionMs": 45123,
"isPlaying": true,
"correctedMs": 1717171717171
}
```
### WebSocket channel
Add a realtime layer to the Flask deployment. Practical options:
- **Recommended for MVP:** [`flask-sock`](https://github.com/miguelgrinberg/flask-sock) (simple, works with existing Flask app) or a small **sidecar ASGI service** if production WSGI cannot hold long-lived connections
- Route: `GET /runbuddy/ws?sessionId=&token=`
- In-memory room registry keyed by `sessionId` (acceptable for 1:1 MVP; document Redis upgrade path if scaling)
**Server responsibilities:**
- Authenticate token → session + role
- Relay JSON messages leader → follower (follower messages limited to `ping`, `leave`, optional `ready`)
- Persist latest snapshot on leader `state` / `position` messages
- Close room and set `status=ended` on leader disconnect or explicit end
### Operational notes
- Add session TTL cleanup cron or lazy expiry on billboard GET
- Rate-limit session creation (already flagged in [`BUGS.md`](BUGS.md))
- WebSocket requires production config change (proxy timeout, upgrade headers) — document in deploy notes
---
## 2. Sync protocol — NTP + event timestamps
### Shared clock helper (new Android class)
[`RunBuddyClock`](app/src/main/java/at/lockstep/player/runbuddy/RunBuddyClock.kt) (proposed):
```kotlin
// offsetMs = local ahead of NTP (from NtpClient)
fun correctedNowMs(): Long = System.currentTimeMillis() - offsetMs
```
- Call `NtpClient.clockOffsetSeconds()` on **session join** (background thread) and optionally **refresh every ~5 min** during active session
- Both peers use the same reference (NTP), not server wall clock — satisfies your requirement and avoids adding a server time endpoint for MVP
### Event types (WebSocket JSON)
| Type | Sender | Fields | Purpose |
|------|--------|--------|---------|
| `transport` | leader | `action`: `play`/`pause`/`seek`/`skip_next`/`skip_prev`/`track_change`, `correctedMs`, `positionMs`, `playlistPosition`, `trackId` | Immediate state changes |
| `position` | leader | `correctedMs`, `positionMs`, `isPlaying`, `playlistPosition`, `trackId` | Heartbeat every **250ms** (match existing [`UPDATE_INTERVAL_MS`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt)) |
| `snapshot` | server | full snapshot | On follower connect / reconnect |
| `session_end` | server | reason | Tear down |
All timestamps in events use **`correctedMs`** (NTP-aligned epoch millis).
### Follower position correction
On each `position` or `transport` event:
```
targetMs = event.positionMs + (event.isPlaying ? followerClock.correctedNowMs() - event.correctedMs : 0)
drift = targetMs - localEngine.getCurrentPositionMs()
if |drift| > 30ms → engine.seekTo(targetMs)
if track mismatch → publishCurrentTrack by playlistPosition/trackId first, then seek
```
- **30ms threshold** is a starting point; tune with real devices
- When paused, do not extrapolate — hold `positionMs` exactly
- Ignore heartbeats while a transport event is being applied (short debounce)
### Leader behavior
- Local UI actions (`requestTogglePause`, `requestSeek`, skip) **also emit** corresponding `transport` events before/after applying locally
- Position heartbeats read `getPlaybackPositionMs()` + `RunBuddyClock.correctedNowMs()`
- Leader local controls remain enabled; follower controls **disabled or no-op** (show "following leader" UI)
---
## 3. Android — new modules and PlaybackService integration
### New packages / files
| File | Role |
|------|------|
| `data/RunBuddyApiClient.kt` | REST: create, list, join, end, leave |
| `runbuddy/RunBuddyWebSocketClient.kt` | OkHttp WebSocket (already on classpath) with reconnect + backoff |
| `runbuddy/RunBuddyClock.kt` | NTP offset cache |
| `runbuddy/RunBuddyCoordinator.kt` | Session lifecycle, role, event encode/decode, snapshot apply |
| `runbuddy/RunBuddySessionState.kt` | `Idle`, `Leading(sessionId)`, `Following(sessionId)` |
Wire clients from [`LockstepApplication`](app/src/main/java/at/lockstep/player/LockstepApplication.kt) (shared `OkHttpClient`).
### PlaybackService changes ([`PlaybackService.kt`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt))
Add a **`RunBuddyCoordinator` reference** (injected via new binder methods or constructor set from NavHost when session starts):
**New public API:**
- `attachRunBuddy(coordinator: RunBuddyCoordinator)`
- `detachRunBuddy()`
- `applyRemoteTransport(...)` — internal mirror of play/pause/seek/skip/track change **without** re-emitting leader events
- `isRunBuddyFollower(): Boolean` — UI uses this to disable controls
**Hook points (existing methods):**
```403:452:app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
private fun setPlaying(playing: Boolean) { ... }
private fun skipDelta(delta: Int) { ... }
fun requestSeek(fraction: Float) { ... }
```
- Leader path: after local mutation, notify coordinator → WS send
- Follower path: coordinator calls `applyRemoteTransport` instead of user `request*` methods
**Track identity:** sync on `currentPlaylistPosition` + `currentTrackId` (not `currentQueueIndex`) to stay stable across paired-only queue indexing — aligns with existing [`PlaybackUiState`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) fields and avoids the unpaired-track index bug called out in [`BUGS.md`](BUGS.md).
### Pre-join validation (follower)
Before `POST .../join`:
1. User must be signed in (existing Spotify token)
2. Local `pairingDao.countPaired(playlistId) >= session.pairedTrackCount` (and ideally equal)
3. Optionally verify each Spotify track id in leader snapshot queue is paired locally — fail fast with clear error if not
### ViewModel ([`LockstepViewModel.kt`](app/src/main/java/at/lockstep/player/LockstepViewModel.kt))
Add thin session orchestration (not playback logic):
- `createRunBuddySession(playlistId)`
- `listPublicSessions()`
- `joinRunBuddySession(sessionId)`
- `endRunBuddySession()`
- Expose `runBuddyState: StateFlow<RunBuddySessionState>` for UI
Keep playback sync inside `PlaybackService` + coordinator (consistent with [`DESIGN.md`](DESIGN.md) foreground-service ownership).
---
## 4. UI flows
### Navigation ([`Routes.kt`](app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt))
Add routes:
- `runbuddy/billboard` — public session list
- Reuse `nowPlaying/{playlistId}` for both roles once connected
### Leader flow
1. Library → select playlist (must be fully paired)
2. **"Start RunBuddy"** action (new button on Library row or playlist detail)
3. Create session → show join code + "Waiting for buddy…"
4. On follower join → navigate to Now Playing, start playlist via existing `startPlaylistPlayback()`
5. Coordinator attaches as **leader**; heartbeats begin
### Follower flow
1. New entry point: **RunBuddy** from Library top bar or Settings
2. [`RunBuddyBillboardScreen`](app/src/main/java/at/lockstep/player/ui/runbuddy/RunBuddyBillboardScreen.kt) — poll or pull-to-refresh `GET /runbuddy/sessions`
3. Tap session → validate pairings → join → navigate to Now Playing + start same playlist
4. Coordinator attaches as **follower**; apply snapshot immediately
### Now Playing UI ([`NowPlayingScreen.kt`](app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt))
- Banner: "Leading RunBuddy" / "Following [host name]"
- Follower: disable play/pause/skip/seek slider (or show read-only progress)
- Leader: **End session** button
- Show sync status indicator (clock synced / WS connected / drift ms for debug builds)
### Settings ([`SettingsScreen.kt`](app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt))
Optional toggle: **"Enable RunBuddy mode"** (feature flag) — can defer if always-on is fine for MVP.
---
## 5. Suggested implementation phases
### Phase A — Server foundation
- DB migration + REST CRUD + billboard list
- Manual test with curl/Postman
### Phase B — WebSocket relay
- Token auth, leader→follower fan-out, snapshot persistence
- Test with `websocat` or a minimal HTML client
### Phase C — Android clients
- `RunBuddyApiClient`, `RunBuddyWebSocketClient`, `RunBuddyClock`
- Unit-test event parsing + follower drift math (pure Kotlin)
### Phase D — Playback integration
- `RunBuddyCoordinator` + `PlaybackService` hooks
- End-to-end two-emulator or two-device test
### Phase E — UI + polish
- Billboard + leader start/waiting screens
- Reconnect (follower re-joins, applies snapshot, resumes heartbeats)
- Session expiry, error toasts, 409 when session full
---
## 6. Risks and mitigations
| Risk | Mitigation |
|------|------------|
| ExoPlayer seek latency causes drift | Periodic 250ms heartbeats + 30ms threshold; pause extrapolation |
| NTP blocked on mobile network | Fail gracefully; show "clock sync failed" and block join |
| Flask WS in production | Verify reverse-proxy WS upgrade; fallback sidecar if needed |
| Unpaired-track index bug | Sync on `playlistPosition`/`trackId`; fix BUGS.md issue in parallel |
| Leader backgrounded | Foreground service already running; WS kept alive in service process |
| Pairing mismatch | Pre-join validation against `pairedTrackCount` + track ids |
---
## 7. Out of scope (MVP)
- Group runs (1:N)
- Cross-playlist or streaming without local MP3s
- Syncing run-data sensor JSON over WS (only playback)
- Server-side beat/metadata sharing per session (reuse existing per-user metadata APIs later)