feat: feed accel to native player

This commit is contained in:
2026-06-14 19:05:39 +02:00
parent c41a758b49
commit 600c1e1407
8 changed files with 118 additions and 0 deletions

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>()
/** /**

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

@@ -50,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()
@@ -213,10 +222,44 @@ class PlaybackService : Service() {
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 =
@@ -243,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
@@ -569,6 +613,8 @@ class PlaybackService : Service() {
override fun onDestroy() { override fun onDestroy() {
stopPlaybackAndTeardown() stopPlaybackAndTeardown()
accelFeeder?.release()
accelFeeder = null
super.onDestroy() super.onDestroy()
} }

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

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