From 600c1e14078ed0b2cf5d1dc52d71e5bf058b7c85 Mon Sep 17 00:00:00 2001 From: David Madl Date: Sun, 14 Jun 2026 19:05:39 +0200 Subject: [PATCH] feat: feed accel to native player --- .../at/lockstep/player/LockstepViewModel.kt | 13 ++++++ .../player/data/UserPreferencesRepository.kt | 13 ++++++ .../player/playback/PlaybackService.kt | 46 +++++++++++++++++++ .../at/lockstep/player/ui/AnnotationScreen.kt | 8 ++++ .../lockstep/player/ui/LockstepAppNavHost.kt | 6 +++ .../at/lockstep/player/ui/NowPlayingScreen.kt | 5 ++ .../player/ui/settings/SettingsScreen.kt | 25 ++++++++++ app/src/main/res/values/strings.xml | 2 + 8 files changed, 118 insertions(+) diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index d90ac46..e6bad7d 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -81,6 +81,13 @@ class LockstepViewModel( false, ) + val feedAccelToPasada: StateFlow = + prefs.feedAccelToPasada.stateIn( + viewModelScope, + SharingStarted.Eagerly, + true, + ) + fun setAnnotationMode(enabled: Boolean) { viewModelScope.launch { prefs.setAnnotationMode(enabled) @@ -93,6 +100,12 @@ class LockstepViewModel( } } + fun setFeedAccelToPasada(enabled: Boolean) { + viewModelScope.launch { + prefs.setFeedAccelToPasada(enabled) + } + } + private val context get() = getApplication() /** diff --git a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt index bc43b1e..992018e 100644 --- a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt +++ b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt @@ -39,6 +39,12 @@ class UserPreferencesRepository( prefs[KEY_COLLECT_RUN_DATA] == true } + /** When true, accelerometer samples are forwarded to libpasada during playback (not while annotating). */ + val feedAccelToPasada: Flow = + dataStore.data.map { prefs -> + prefs[KEY_FEED_ACCEL_TO_PASADA] != false + } + suspend fun setOnboardingComplete(done: Boolean) { dataStore.edit { prefs -> 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 { private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token") private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode") private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data") + private val KEY_FEED_ACCEL_TO_PASADA = booleanPreferencesKey("feed_accel_to_pasada") } } 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 2ff0b12..97ce17f 100644 --- a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt +++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt @@ -50,9 +50,18 @@ class PlaybackService : Service() { private val binder = LocalBinder() private var engine: MusicPlayerEngine? = null + private var accelFeeder: PasadaAccelFeeder? = null private var positionPollJob: 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()) val uiState: StateFlow = _uiState.asStateFlow() @@ -213,10 +222,44 @@ class PlaybackService : Service() { it.setListener(engineListener) it.initSession() engine = it + refreshAccelFeeding() 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() { positionPollJob?.cancel() positionPollJob = @@ -243,6 +286,7 @@ class PlaybackService : Service() { positionPollJob = null positionCachePollJob?.cancel() positionCachePollJob = null + stopAccelFeeding() engine?.setListener(null) engine?.releaseSession() engine = null @@ -569,6 +613,8 @@ class PlaybackService : Service() { override fun onDestroy() { stopPlaybackAndTeardown() + accelFeeder?.release() + accelFeeder = null super.onDestroy() } diff --git a/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt b/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt index 80d8343..3e6f23b 100644 --- a/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -85,6 +86,13 @@ fun AnnotationRoute( } } + DisposableEffect(playback) { + playback?.setAnnotating(true) + onDispose { + playback?.setAnnotating(false) + } + } + val beatTimesMs = remember { mutableStateListOf() } val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() } diff --git a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt index 9788094..2b1fae4 100644 --- a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt +++ b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt @@ -8,6 +8,7 @@ import android.os.IBinder import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,8 +35,13 @@ fun LockstepAppNavHost( viewModel: LockstepViewModel, ) { val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle() + val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle() var playback by remember { mutableStateOf(null) } + LaunchedEffect(feedAccelToPasada, playback) { + playback?.setFeedAccelEnabled(feedAccelToPasada) + } + DisposableEffect(Unit) { val connection = object : ServiceConnection { diff --git a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt index 9c945c4..09a6287 100644 --- a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt @@ -234,6 +234,11 @@ fun NowPlayingRoute( } } + DisposableEffect(playback) { + playback?.setAnnotating(false) + onDispose { } + } + LaunchedEffect(collectRunData, playback) { if (!collectRunData) { collector.setCollectingEnabled(false) diff --git a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt index 1396217..ca6261b 100644 --- a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt @@ -38,6 +38,7 @@ fun SettingsScreen( val context = LocalContext.current val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle() val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle() + val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle() Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -83,6 +84,30 @@ fun SettingsScreen( 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( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e73b6b..2bfffc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing. Collect run data When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder. + Adapt playback to movement + When enabled, accelerometer data is sent to libpasada during Now playing so tempo can follow your pace. Disabled automatically on the beat annotation screen. Beat annotation Tap on each beat; times use the same clock as playback (ExoPlayer position). Tap here on the beat