feat: also sync beat annotations to api, store beat annotations in Documents
This commit is contained in:
@@ -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<Long>,
|
||||
) {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long>() }
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Long>,
|
||||
): 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. */
|
||||
|
||||
@@ -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<RunAccelSample>): JSONArray {
|
||||
val array = JSONArray()
|
||||
for (sample in samples) {
|
||||
|
||||
Reference in New Issue
Block a user