2 Commits

6 changed files with 70 additions and 34 deletions

View File

@@ -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**. 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 ## 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. - **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.

View File

@@ -167,12 +167,13 @@ class LockstepViewModel(
} }
/** /**
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described * Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
* by [event]. Skips when [beatTimesMs] is empty. * by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty.
*/ */
fun persistBeatAnnotation( fun persistBeatAnnotation(
playlistId: String, playlistId: String,
playlistDisplayName: String, playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent, event: TrackBoundaryEvent,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
) { ) {
@@ -183,15 +184,26 @@ class LockstepViewModel(
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
val contentId = docId.ifBlank { event.trackId } val contentId = docId.ifBlank { event.trackId }
BeatAnnotationStorage.writeAnnotationsFile( val uri =
context = getApplication(), BeatAnnotationStorage.writeAnnotationsFile(
playlistDisplayName = playlistDisplayName, context = getApplication(),
trackQueueIndex0Based = event.queueIndex, sessionFolder = sessionFolder,
contentId = contentId, playlistDisplayName = playlistDisplayName,
title = event.title, trackQueueIndex0Based = event.queueIndex,
artist = event.artist, contentId = contentId,
beatTimesMs = beatTimesMs, 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) } val failures = pending.count { entry -> !syncMetadataEntry(entry) }
if (failures > 0) { if (failures > 0) {
"Failed to sync $failures collection(s)" "Failed to sync $failures file(s)"
} else { } else {
null null
} }

View File

@@ -11,7 +11,7 @@ data class FileMetadataEntity(
val fileUri: String, val fileUri: String,
/** Spotify track id for the collected run. */ /** Spotify track id for the collected run. */
val trackId: String, val trackId: String,
/** e.g. [TYPE_COLLECTION] for run-data JSON files. */ /** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */
val type: String, val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */ /** [BuildConfig.VERSION_CODE] at write time. */
val version: Int, val version: Int,
@@ -19,5 +19,6 @@ data class FileMetadataEntity(
) { ) {
companion object { companion object {
const val TYPE_COLLECTION = "collection" const val TYPE_COLLECTION = "collection"
const val TYPE_ANNOTATION = "annotation"
} }
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -32,11 +33,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.lockstep.player.LockstepViewModel import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R import at.lockstep.player.R
import at.lockstep.player.playback.PlaybackService import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.util.RunDataStorage
/** /**
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player] * Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
@@ -53,6 +56,7 @@ fun AnnotationRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val beatListHeight = LocalConfiguration.current.screenHeightDp.dp / 5
var ui by remember { var ui by remember {
mutableStateOf( mutableStateOf(
NowPlayingUiState( NowPlayingUiState(
@@ -82,6 +86,7 @@ fun AnnotationRoute(
} }
val beatTimesMs = remember { mutableStateListOf<Long>() } val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
var playlistDisplayName by remember { mutableStateOf("playlist") } var playlistDisplayName by remember { mutableStateOf("playlist") }
@@ -89,12 +94,18 @@ fun AnnotationRoute(
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
} }
LaunchedEffect(playback, playlistId, playlistDisplayName) { LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
val service = playback ?: return@LaunchedEffect val service = playback ?: return@LaunchedEffect
service.trackBoundaryEvents.collect { event -> service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList() val snapshot = beatTimesMs.toList()
beatTimesMs.clear() beatTimesMs.clear()
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot) viewModel.persistBeatAnnotation(
playlistId,
playlistDisplayName,
annotationSessionFolder,
event,
snapshot,
)
} }
} }
@@ -174,7 +185,7 @@ fun AnnotationRoute(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 160.dp) .height(beatListHeight)
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {

View File

@@ -5,28 +5,24 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.util.Locale import java.util.Locale
object BeatAnnotationStorage { 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( fun writeAnnotationsFile(
context: Context, context: Context,
sessionFolder: String,
playlistDisplayName: String, playlistDisplayName: String,
trackQueueIndex0Based: Int, trackQueueIndex0Based: Int,
contentId: String, contentId: String,
title: String, title: String,
artist: String, artist: String,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
): File { ): Uri? {
val safeName = val safeName =
playlistDisplayName playlistDisplayName
.replace(Regex("[\\\\/:*?\"<>|]"), "_") .replace(Regex("[\\\\/:*?\"<>|]"), "_")
@@ -35,17 +31,16 @@ object BeatAnnotationStorage {
.take(120) .take(120)
val suffix = val suffix =
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) 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 sec = beatTimesMs.map { it / 1000.0 }
val json = val jsonString =
JSONObject().apply { JSONObject().apply {
put("contentId", contentId) put("contentId", contentId)
put("title", title) put("title", title)
put("artist", artist) put("artist", artist)
put("beatTimesSec", JSONArray(sec)) put("beatTimesSec", JSONArray(sec))
} }.toString(2)
file.writeText(json.toString(2)) return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
return file
} }
/** Document id for a content [Uri] when available; otherwise last path segment. */ /** Document id for a content [Uri] when available; otherwise last path segment. */

View File

@@ -58,13 +58,22 @@ object RunDataStorage {
put("versionCode", BuildConfig.VERSION_CODE) put("versionCode", BuildConfig.VERSION_CODE)
}.toString() }.toString()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(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<RunAccelSample>): JSONArray { private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
val array = JSONArray() val array = JSONArray()
for (sample in samples) { for (sample in samples) {