From e936dee142de5c9f417730b20735cf69958bd6dd Mon Sep 17 00:00:00 2001 From: David Madl Date: Sat, 30 May 2026 19:55:42 +0200 Subject: [PATCH] feat: also sync beat annotations to api, store beat annotations in Documents --- DESIGN.md | 8 +++++ .../at/lockstep/player/LockstepViewModel.kt | 36 ++++++++++++------- .../player/data/db/FileMetadataEntity.kt | 3 +- .../at/lockstep/player/ui/AnnotationScreen.kt | 12 +++++-- .../player/util/BeatAnnotationStorage.kt | 21 +++++------ .../at/lockstep/player/util/RunDataStorage.kt | 19 +++++++--- 6 files changed, 66 insertions(+), 33 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 448407d..497a093 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,6 +2,14 @@ Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**. +## UI screens + +- Library: shows the playlists of the user +- Annotation: user presses a button to annotate the beat +- Now Playing: player showing an individual track, with pause/previous/next buttons + - Collection mode: app records sensor data during a run +- Settings: allows to enable modes above, logout button + ## Already captured from SPECS.md - **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback. diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 3264bf7..dd1fbe9 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -167,12 +167,13 @@ class LockstepViewModel( } /** - * Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described - * by [event]. Skips when [beatTimesMs] is empty. + * Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described + * by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty. */ fun persistBeatAnnotation( playlistId: String, playlistDisplayName: String, + sessionFolder: String, event: TrackBoundaryEvent, beatTimesMs: List, ) { @@ -183,15 +184,26 @@ class LockstepViewModel( val pairing = pairingDao.findForTrack(playlistId, event.trackId) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val contentId = docId.ifBlank { event.trackId } - BeatAnnotationStorage.writeAnnotationsFile( - context = getApplication(), - playlistDisplayName = playlistDisplayName, - trackQueueIndex0Based = event.queueIndex, - contentId = contentId, - title = event.title, - artist = event.artist, - beatTimesMs = beatTimesMs, - ) + val uri = + BeatAnnotationStorage.writeAnnotationsFile( + context = getApplication(), + sessionFolder = sessionFolder, + playlistDisplayName = playlistDisplayName, + trackQueueIndex0Based = event.queueIndex, + contentId = contentId, + title = event.title, + artist = event.artist, + beatTimesMs = beatTimesMs, + ) ?: return@launch + val entry = + FileMetadataEntity( + fileUri = uri.toString(), + trackId = event.trackId, + type = FileMetadataEntity.TYPE_ANNOTATION, + version = BuildConfig.VERSION_CODE, + ) + val id = fileMetadataDao.insert(entry) + syncMetadataEntry(entry.copy(id = id)) } } @@ -299,7 +311,7 @@ class LockstepViewModel( } val failures = pending.count { entry -> !syncMetadataEntry(entry) } if (failures > 0) { - "Failed to sync $failures collection(s)" + "Failed to sync $failures file(s)" } else { null } diff --git a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt index 43df688..cb523d4 100644 --- a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt @@ -11,7 +11,7 @@ data class FileMetadataEntity( val fileUri: String, /** Spotify track id for the collected run. */ val trackId: String, - /** e.g. [TYPE_COLLECTION] for run-data JSON files. */ + /** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */ val type: String, /** [BuildConfig.VERSION_CODE] at write time. */ val version: Int, @@ -19,5 +19,6 @@ data class FileMetadataEntity( ) { companion object { const val TYPE_COLLECTION = "collection" + const val TYPE_ANNOTATION = "annotation" } } 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 592b201..ef2f0fa 100644 --- a/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import at.lockstep.player.LockstepViewModel import at.lockstep.player.R import at.lockstep.player.playback.PlaybackService +import at.lockstep.player.util.RunDataStorage /** * Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player] @@ -82,6 +83,7 @@ fun AnnotationRoute( } val beatTimesMs = remember { mutableStateListOf() } + val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() } var playlistDisplayName by remember { mutableStateOf("playlist") } @@ -89,12 +91,18 @@ fun AnnotationRoute( playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) } - LaunchedEffect(playback, playlistId, playlistDisplayName) { + LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) { val service = playback ?: return@LaunchedEffect service.trackBoundaryEvents.collect { event -> val snapshot = beatTimesMs.toList() beatTimesMs.clear() - viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot) + viewModel.persistBeatAnnotation( + playlistId, + playlistDisplayName, + annotationSessionFolder, + event, + snapshot, + ) } } diff --git a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt index cdee46b..10a6ef1 100644 --- a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt @@ -5,28 +5,24 @@ import android.net.Uri import android.provider.DocumentsContract import org.json.JSONArray import org.json.JSONObject -import java.io.File import java.util.Locale object BeatAnnotationStorage { - private const val DIR_NAME = "beat_annotations" - - fun annotationsDir(context: Context): File = - File(context.filesDir, DIR_NAME).apply { mkdirs() } - /** - * [playlistDisplayName] + "_" + 1-based track index (000) + ".json". + * [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under + * Documents/Lockstep/{sessionFolder}/. */ fun writeAnnotationsFile( context: Context, + sessionFolder: String, playlistDisplayName: String, trackQueueIndex0Based: Int, contentId: String, title: String, artist: String, beatTimesMs: List, - ): File { + ): Uri? { val safeName = playlistDisplayName .replace(Regex("[\\\\/:*?\"<>|]"), "_") @@ -35,17 +31,16 @@ object BeatAnnotationStorage { .take(120) val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) - val file = File(annotationsDir(context), "${safeName}_$suffix.json") + val fileName = "${safeName}_$suffix.json" val sec = beatTimesMs.map { it / 1000.0 } - val json = + val jsonString = JSONObject().apply { put("contentId", contentId) put("title", title) put("artist", artist) put("beatTimesSec", JSONArray(sec)) - } - file.writeText(json.toString(2)) - return file + }.toString(2) + return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString) } /** Document id for a content [Uri] when available; otherwise last path segment. */ diff --git a/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt index 08515b2..05c4781 100644 --- a/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt @@ -58,13 +58,22 @@ object RunDataStorage { put("versionCode", BuildConfig.VERSION_CODE) }.toString() - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - writeViaMediaStore(context, runSessionFolder, fileName, jsonString) - } else { - writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString) - } + return writePublicJsonFile(context, runSessionFolder, fileName, jsonString) } + /** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */ + fun writePublicJsonFile( + context: Context, + sessionFolder: String, + fileName: String, + jsonString: String, + ): Uri? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + writeViaMediaStore(context, sessionFolder, fileName, jsonString) + } else { + writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString) + } + private fun accelToJsonArray(samples: List): JSONArray { val array = JSONArray() for (sample in samples) {