Compare commits
13 Commits
8d1b762b78
...
feature/ru
| Author | SHA1 | Date | |
|---|---|---|---|
| e823fc9d94 | |||
| 965bec72d2 | |||
| 922cc5d82a | |||
| 88db4ad18d | |||
| a10102c10a | |||
| 19bbccf244 | |||
| f7c5b932e9 | |||
| 633afd115d | |||
| 12adfbc960 | |||
| b332c7b2f3 | |||
| 306ad83d12 | |||
| 9d161bd2a9 | |||
| ad2dc18f00 |
4
BUGS.md
@@ -1,6 +1,6 @@
|
|||||||
# Bugs
|
# Bugs
|
||||||
|
|
||||||
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||||
|
|
||||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
|
|
||||||
## Nice-to
|
## Nice-to
|
||||||
|
|
||||||
|
- beat metadata is handled by the app, currently. The handling should move into "jukebox".
|
||||||
|
|
||||||
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
||||||
|
|
||||||
- TrackFileMatching, scoreAgainstLocalHints
|
- TrackFileMatching, scoreAgainstLocalHints
|
||||||
|
|||||||
59
DESIGN.md
@@ -4,12 +4,71 @@ Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md)
|
|||||||
|
|
||||||
## UI screens
|
## UI screens
|
||||||
|
|
||||||
|
- Onboarding: explains why notifications are necessary, implements login
|
||||||
- Library: shows the playlists of the user
|
- Library: shows the playlists of the user
|
||||||
- Annotation: user presses a button to annotate the beat
|
- Annotation: user presses a button to annotate the beat
|
||||||
- Now Playing: player showing an individual track, with pause/previous/next buttons
|
- Now Playing: player showing an individual track, with pause/previous/next buttons
|
||||||
- Collection mode: app records sensor data during a run
|
- Collection mode: app records sensor data during a run
|
||||||
- Settings: allows to enable modes above, logout button
|
- Settings: allows to enable modes above, logout button
|
||||||
|
|
||||||
|
## Data files
|
||||||
|
|
||||||
|
Filesystem layout of JSON files on primary storage (e.g. Pixel 5: `/storage/emulated/0/Documents/Lockstep/`):
|
||||||
|
|
||||||
|
```
|
||||||
|
/storage/emulated/0/Documents/Lockstep/
|
||||||
|
├── 2026-05-31_10-15-30/ ← annotation session (TYPE_ANNOTATION)
|
||||||
|
│ ├── Running Mix_001.json
|
||||||
|
│ └── Running Mix_002.json
|
||||||
|
├── 2026-05-31_14-22-05/ ← now-playing collection session (TYPE_COLLECTION)
|
||||||
|
│ ├── Running Mix_001.json
|
||||||
|
│ └── Running Mix_002.json
|
||||||
|
└── Beats/ ← canonical beats per playlist (TYPE_BEATS)
|
||||||
|
└── Running Mix/
|
||||||
|
├── 001.json
|
||||||
|
└── 002.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each type is recorded in Room `file_metadata` (`FileMetadataEntity`) with `fileUri`, `trackId`, `type`, `version`, `synced`, and `lastSyncedAt` (epoch millis).
|
||||||
|
|
||||||
|
| Type | Constant | Written by | Path pattern | Server sync |
|
||||||
|
|------|----------|------------|--------------|-------------|
|
||||||
|
| **Annotation session** | `TYPE_ANNOTATION` | Annotation screen (`BeatAnnotationStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per annotation session | Yes — uploaded via `POST /metadata` when signed in |
|
||||||
|
| **Run collection** | `TYPE_COLLECTION` | Now Playing with collect-run-data enabled (`RunDataStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per run session | Yes |
|
||||||
|
| **Canonical beats** | `TYPE_BEATS` | Annotation screen (`BeatAnnotationStorage`) — written alongside each session annotation | `Documents/Lockstep/Beats/{playlist_name}/{NNN}.json` — one folder per playlist; **overwrites** on re-annotation | **No** — tracked locally only; `synced = 1` and `lastSyncedAt` set at write time |
|
||||||
|
|
||||||
|
During an **annotation session**, leaving a track writes **both** `TYPE_ANNOTATION` (append-only session archive) and `TYPE_BEATS` (latest beats for that playlist slot).
|
||||||
|
|
||||||
|
### Beat JSON schema (`TYPE_ANNOTATION` and `TYPE_BEATS`)
|
||||||
|
|
||||||
|
Same schema for session annotations and canonical beats:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contentId": "primary:Documents/Music/track.mp3",
|
||||||
|
"title": "Track Title",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"beatTimesSec": [1.23, 2.45, 3.67]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`contentId`** — SAF document id of the paired local MP3 when available; otherwise Spotify track id.
|
||||||
|
- **`beatTimesSec`** — user-tapped beat times in seconds (playback position).
|
||||||
|
|
||||||
|
### Collection JSON schema (`TYPE_COLLECTION`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [ { "timestamp": 0, "positionMs": 0, "values": [0.0, 0.0, 9.8] } ],
|
||||||
|
"gyro": [ { "timestamp": 0, "values": [0.0, 0.0, 0.0] } ],
|
||||||
|
"gps": [ { "timestamp": 0, "values": [48.2, 16.3, 200.0] } ],
|
||||||
|
"meta": "content://…",
|
||||||
|
"title": "Track Title",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"versionCode": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LockstepPlayer">
|
android:theme="@style/Theme.LockstepPlayer">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.lockstep.jukebox.api.LockstepApiException
|
import at.lockstep.jukebox.api.LockstepApiException
|
||||||
import at.lockstep.jukebox.db.TrackRow
|
import at.lockstep.jukebox.db.TrackRow
|
||||||
|
import at.lockstep.player.data.MetadataFetchResult
|
||||||
import at.lockstep.player.data.UserPreferencesRepository
|
import at.lockstep.player.data.UserPreferencesRepository
|
||||||
import at.lockstep.player.data.db.FileMetadataEntity
|
import at.lockstep.player.data.db.FileMetadataEntity
|
||||||
import at.lockstep.player.data.db.TrackPairingEntity
|
import at.lockstep.player.data.db.TrackPairingEntity
|
||||||
@@ -167,8 +168,9 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
|
* Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
|
||||||
* by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty.
|
* and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
|
||||||
|
* Skips when [beatTimesMs] is empty.
|
||||||
*/
|
*/
|
||||||
fun persistBeatAnnotation(
|
fun persistBeatAnnotation(
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
@@ -184,29 +186,78 @@ 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 }
|
||||||
val uri =
|
val appContext = getApplication<Application>()
|
||||||
|
val annotationUri =
|
||||||
BeatAnnotationStorage.writeAnnotationsFile(
|
BeatAnnotationStorage.writeAnnotationsFile(
|
||||||
context = getApplication(),
|
context = appContext,
|
||||||
sessionFolder = sessionFolder,
|
sessionFolder = sessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
beatTimesMs = beatTimesMs,
|
beatTimesMs = beatTimesMs,
|
||||||
) ?: return@launch
|
) ?: return@launch
|
||||||
val entry =
|
recordMetadataEntry(
|
||||||
FileMetadataEntity(
|
fileUri = annotationUri.toString(),
|
||||||
fileUri = uri.toString(),
|
|
||||||
trackId = event.trackId,
|
trackId = event.trackId,
|
||||||
type = FileMetadataEntity.TYPE_ANNOTATION,
|
type = FileMetadataEntity.TYPE_ANNOTATION,
|
||||||
|
)
|
||||||
|
val beatsUri =
|
||||||
|
BeatAnnotationStorage.writeBeatsFile(
|
||||||
|
context = appContext,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
|
contentId = contentId,
|
||||||
|
title = event.title,
|
||||||
|
artist = event.artist,
|
||||||
|
beatTimesMs = beatTimesMs,
|
||||||
|
) ?: return@launch
|
||||||
|
recordOrUpdateMetadataEntry(
|
||||||
|
fileUri = beatsUri.toString(),
|
||||||
|
trackId = event.trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recordMetadataEntry(
|
||||||
|
fileUri: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val beats = type == FileMetadataEntity.TYPE_BEATS
|
||||||
|
val entry =
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = fileUri,
|
||||||
|
trackId = trackId,
|
||||||
|
type = type,
|
||||||
version = BuildConfig.VERSION_CODE,
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = beats,
|
||||||
|
lastSyncedAt = if (beats) now else null,
|
||||||
)
|
)
|
||||||
val id = fileMetadataDao.insert(entry)
|
val id = fileMetadataDao.insert(entry)
|
||||||
|
if (!beats) {
|
||||||
syncMetadataEntry(entry.copy(id = id))
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun recordOrUpdateMetadataEntry(
|
||||||
|
fileUri: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
) {
|
||||||
|
val version = BuildConfig.VERSION_CODE
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val existing = fileMetadataDao.findByTrackIdAndType(trackId, type)
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.updateFile(existing.id, fileUri, version, now)
|
||||||
|
} else {
|
||||||
|
recordMetadataEntry(fileUri, trackId, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
|
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
|
||||||
* Skips when [samples] is empty or the track has no paired local URI.
|
* Skips when [samples] is empty or the track has no paired local URI.
|
||||||
@@ -227,7 +278,7 @@ class LockstepViewModel(
|
|||||||
writeRunDataAndRecordMetadata(
|
writeRunDataAndRecordMetadata(
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
trackId = event.trackId,
|
trackId = event.trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
@@ -245,7 +296,7 @@ class LockstepViewModel(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
queueIndex: Int,
|
playlistPosition: Int,
|
||||||
snapshot: RunTrackDataSnapshot,
|
snapshot: RunTrackDataSnapshot,
|
||||||
) {
|
) {
|
||||||
if (snapshot.isEmpty()) {
|
if (snapshot.isEmpty()) {
|
||||||
@@ -257,7 +308,7 @@ class LockstepViewModel(
|
|||||||
writeRunDataAndRecordMetadata(
|
writeRunDataAndRecordMetadata(
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = queueIndex,
|
trackQueueIndex0Based = playlistPosition,
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -299,6 +350,125 @@ class LockstepViewModel(
|
|||||||
syncMetadataEntry(entry.copy(id = id))
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches beat metadata from the server for each track in [playlistId] that has no local beats file.
|
||||||
|
* Records [FileMetadataEntity.lastFetchAttemptAt] when the server returns 404 so polling can retry later.
|
||||||
|
*/
|
||||||
|
fun fetchBeatsMetadataForPlaylist(
|
||||||
|
playlistId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
|
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||||
|
for (track in tracks) {
|
||||||
|
val trackId = track.trackId ?: continue
|
||||||
|
fetchBeatMetadataForTrack(
|
||||||
|
token = token,
|
||||||
|
trackId = trackId,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
queueIndex = track.position,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchBeatMetadataForTrack(
|
||||||
|
token: String,
|
||||||
|
trackId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
queueIndex: Int,
|
||||||
|
) {
|
||||||
|
val existing =
|
||||||
|
fileMetadataDao.findByTrackIdAndType(trackId, FileMetadataEntity.TYPE_BEATS)
|
||||||
|
if (existing != null && existing.fileUri.isNotBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (existing?.lastFetchAttemptAt != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val attemptAt = System.currentTimeMillis()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
app.metadataSyncClient.fetchMetadata(
|
||||||
|
accessToken = token,
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(METADATA_TAG, "beat metadata fetch failed trackId=$trackId", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is MetadataFetchResult.Found -> {
|
||||||
|
val beatsUri =
|
||||||
|
BeatAnnotationStorage.writeBeatsFileFromJson(
|
||||||
|
context = getApplication(),
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = queueIndex,
|
||||||
|
json = result.collection,
|
||||||
|
) ?: return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.updateFile(existing.id, beatsUri.toString(), BuildConfig.VERSION_CODE, now)
|
||||||
|
} else {
|
||||||
|
fileMetadataDao.insert(
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = beatsUri.toString(),
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = true,
|
||||||
|
lastSyncedAt = now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(METADATA_TAG, "fetched beat metadata from server trackId=$trackId")
|
||||||
|
}
|
||||||
|
MetadataFetchResult.NotFound -> {
|
||||||
|
recordBeatMetadataUnavailable(trackId, attemptAt, existing)
|
||||||
|
Log.d(METADATA_TAG, "beat metadata not on server trackId=$trackId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recordBeatMetadataUnavailable(
|
||||||
|
trackId: String,
|
||||||
|
attemptAt: Long,
|
||||||
|
existing: FileMetadataEntity?,
|
||||||
|
) {
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.recordFetchAttempt(existing.id, attemptAt)
|
||||||
|
} else {
|
||||||
|
fileMetadataDao.insert(
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = "",
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = false,
|
||||||
|
lastFetchAttemptAt = attemptAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
|
||||||
|
@Volatile
|
||||||
|
private var suppressNextLibraryMetadataSync = false
|
||||||
|
|
||||||
|
fun suppressNextLibraryMetadataSync() {
|
||||||
|
suppressNextLibraryMetadataSync = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
|
||||||
|
if (!suppressNextLibraryMetadataSync) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suppressNextLibraryMetadataSync = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncPendingMetadata(): String? {
|
suspend fun syncPendingMetadata(): String? {
|
||||||
val token = spotifyAccessToken.value
|
val token = spotifyAccessToken.value
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) {
|
||||||
@@ -333,7 +503,7 @@ class LockstepViewModel(
|
|||||||
version = entry.version,
|
version = entry.version,
|
||||||
collection = collection,
|
collection = collection,
|
||||||
)
|
)
|
||||||
fileMetadataDao.markSynced(entry.id)
|
fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
|
||||||
true
|
true
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
|
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.data
|
package at.lockstep.player.data
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -7,10 +8,56 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
|
sealed class MetadataFetchResult {
|
||||||
|
data class Found(val collection: JSONObject) : MetadataFetchResult()
|
||||||
|
|
||||||
|
data object NotFound : MetadataFetchResult()
|
||||||
|
}
|
||||||
|
|
||||||
class MetadataSyncClient(
|
class MetadataSyncClient(
|
||||||
private val httpClient: OkHttpClient,
|
private val httpClient: OkHttpClient,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
) {
|
) {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun fetchMetadata(
|
||||||
|
accessToken: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
): MetadataFetchResult {
|
||||||
|
val url =
|
||||||
|
baseUrl.toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addPathSegments("metadata")
|
||||||
|
.addQueryParameter("trackId", trackId)
|
||||||
|
.addQueryParameter("type", type)
|
||||||
|
.build()
|
||||||
|
val request =
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 404) {
|
||||||
|
return MetadataFetchResult.NotFound
|
||||||
|
}
|
||||||
|
val bodyText = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val message =
|
||||||
|
runCatching { JSONObject(bodyText).optString("error") }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: response.message
|
||||||
|
throw IOException(message)
|
||||||
|
}
|
||||||
|
val collection =
|
||||||
|
runCatching { JSONObject(bodyText).getJSONObject("collection") }
|
||||||
|
.getOrNull()
|
||||||
|
?: throw IOException("Missing collection in response")
|
||||||
|
return MetadataFetchResult.Found(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun uploadCollection(
|
fun uploadCollection(
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UserPreferencesRepository(
|
|||||||
prefs[KEY_ANNOTATION_MODE] == true
|
prefs[KEY_ANNOTATION_MODE] == true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */
|
/** When true, Now Playing records sensor samples per track into JSON under Documents/Lockstep/{sessionFolder}/. */
|
||||||
val collectRunData: Flow<Boolean> =
|
val collectRunData: Flow<Boolean> =
|
||||||
dataStore.data.map { prefs ->
|
dataStore.data.map { prefs ->
|
||||||
prefs[KEY_COLLECT_RUN_DATA] == true
|
prefs[KEY_COLLECT_RUN_DATA] == true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
|||||||
TrackPairingEntity::class,
|
TrackPairingEntity::class,
|
||||||
FileMetadataEntity::class,
|
FileMetadataEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 6,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase :
|
abstract class AppDatabase :
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ interface FileMetadataDao {
|
|||||||
@Insert
|
@Insert
|
||||||
suspend fun insert(row: FileMetadataEntity): Long
|
suspend fun insert(row: FileMetadataEntity): Long
|
||||||
|
|
||||||
@Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC")
|
@Query("SELECT * FROM file_metadata WHERE synced = 0 AND type != 'beats' ORDER BY id ASC")
|
||||||
suspend fun listUnsynced(): List<FileMetadataEntity>
|
suspend fun listUnsynced(): List<FileMetadataEntity>
|
||||||
|
|
||||||
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
|
@Query("UPDATE file_metadata SET synced = 1, lastSyncedAt = :syncedAt WHERE id = :id")
|
||||||
suspend fun markSynced(id: Long)
|
suspend fun markSynced(id: Long, syncedAt: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
|
||||||
|
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
|
||||||
|
|
||||||
|
@Query("UPDATE file_metadata SET lastFetchAttemptAt = :attemptAt WHERE id = :id")
|
||||||
|
suspend fun recordFetchAttempt(id: Long, attemptAt: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt, lastFetchAttemptAt = NULL WHERE id = :id",
|
||||||
|
)
|
||||||
|
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ 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] or [TYPE_ANNOTATION]. */
|
/** e.g. [TYPE_COLLECTION], [TYPE_ANNOTATION], or [TYPE_BEATS]. */
|
||||||
val type: String,
|
val type: String,
|
||||||
/** [BuildConfig.VERSION_CODE] at write time. */
|
/** [BuildConfig.VERSION_CODE] at write time. */
|
||||||
val version: Int,
|
val version: Int,
|
||||||
val synced: Boolean = false,
|
val synced: Boolean = false,
|
||||||
|
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
|
||||||
|
val lastSyncedAt: Long? = null,
|
||||||
|
/** Epoch millis of the last server fetch attempt when beat metadata was unavailable (404). */
|
||||||
|
val lastFetchAttemptAt: Long? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val TYPE_COLLECTION = "collection"
|
const val TYPE_COLLECTION = "collection"
|
||||||
const val TYPE_ANNOTATION = "annotation"
|
const val TYPE_ANNOTATION = "annotation"
|
||||||
|
/** Canonical beat files under Documents/Lockstep/Beats/; tracked locally, not synced to server. */
|
||||||
|
const val TYPE_BEATS = "beats"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private var queue: List<TrackQueueItem> = emptyList()
|
private var queue: List<TrackQueueItem> = emptyList()
|
||||||
private var index: Int = 0
|
private var index: Int = 0
|
||||||
|
private var tornDown = false
|
||||||
|
|
||||||
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -109,6 +110,7 @@ class PlaybackService : Service() {
|
|||||||
title = item.title,
|
title = item.title,
|
||||||
artist = item.artist,
|
artist = item.artist,
|
||||||
queueIndex = i,
|
queueIndex = i,
|
||||||
|
playlistPosition = item.playlistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
reason = reason,
|
reason = reason,
|
||||||
),
|
),
|
||||||
@@ -264,6 +266,7 @@ class PlaybackService : Service() {
|
|||||||
artist = row.artistName ?: "—",
|
artist = row.artistName ?: "—",
|
||||||
localUri = Uri.parse(uriStr),
|
localUri = Uri.parse(uriStr),
|
||||||
durationMsHint = hint,
|
durationMsHint = hint,
|
||||||
|
playlistPosition = row.position,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
index = 0
|
index = 0
|
||||||
@@ -299,6 +302,7 @@ class PlaybackService : Service() {
|
|||||||
isPlaying = _uiState.value.isPlaying,
|
isPlaying = _uiState.value.isPlaying,
|
||||||
currentTrackId = item.id,
|
currentTrackId = item.id,
|
||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
|
currentPlaylistPosition = item.playlistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updateProgressFromEngine()
|
updateProgressFromEngine()
|
||||||
@@ -366,6 +370,8 @@ class PlaybackService : Service() {
|
|||||||
durationSeconds = durationSec,
|
durationSeconds = durationSec,
|
||||||
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
|
currentPlaylistPosition =
|
||||||
|
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updatePlaybackStateFromEngine()
|
updatePlaybackStateFromEngine()
|
||||||
@@ -524,15 +530,37 @@ class PlaybackService : Service() {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
stopPlaybackAndTeardown()
|
||||||
|
stopSelf()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
stopPlaybackAndTeardown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
|
||||||
|
private fun stopPlaybackAndTeardown() {
|
||||||
|
if (tornDown) return
|
||||||
|
tornDown = true
|
||||||
positionPollJob?.cancel()
|
positionPollJob?.cancel()
|
||||||
|
positionPollJob = null
|
||||||
positionCachePollJob?.cancel()
|
positionCachePollJob?.cancel()
|
||||||
|
positionCachePollJob = null
|
||||||
releaseEngine()
|
releaseEngine()
|
||||||
|
queue = emptyList()
|
||||||
|
index = 0
|
||||||
|
cachedPlaybackPositionMs = 0L
|
||||||
|
_uiState.value = PlaybackUiState.initial()
|
||||||
|
if (::mediaSession.isInitialized) {
|
||||||
mediaSession.run {
|
mediaSession.run {
|
||||||
isActive = false
|
isActive = false
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlaybackUiState(
|
data class PlaybackUiState(
|
||||||
@@ -543,6 +571,8 @@ class PlaybackService : Service() {
|
|||||||
val isPlaying: Boolean,
|
val isPlaying: Boolean,
|
||||||
val currentTrackId: String?,
|
val currentTrackId: String?,
|
||||||
val currentQueueIndex: Int,
|
val currentQueueIndex: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist for the current track. */
|
||||||
|
val currentPlaylistPosition: Int,
|
||||||
val queueSize: Int,
|
val queueSize: Int,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -555,6 +585,7 @@ class PlaybackService : Service() {
|
|||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
currentTrackId = null,
|
currentTrackId = null,
|
||||||
currentQueueIndex = 0,
|
currentQueueIndex = 0,
|
||||||
|
currentPlaylistPosition = 0,
|
||||||
queueSize = 0,
|
queueSize = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -567,6 +598,8 @@ class PlaybackService : Service() {
|
|||||||
val localUri: Uri?,
|
val localUri: Uri?,
|
||||||
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||||
val durationMsHint: Int,
|
val durationMsHint: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist. */
|
||||||
|
val playlistPosition: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ data class TrackBoundaryEvent(
|
|||||||
val trackId: String,
|
val trackId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
/** 0-based index in the current play queue when this track was current. */
|
/** 0-based index in the paired-only play queue when this track was current. */
|
||||||
val queueIndex: Int,
|
val queueIndex: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist (used for beat/run-data file slots). */
|
||||||
|
val playlistPosition: Int,
|
||||||
val queueSize: Int,
|
val queueSize: Int,
|
||||||
val reason: TrackBoundaryReason,
|
val reason: TrackBoundaryReason,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,17 +91,21 @@ fun AnnotationRoute(
|
|||||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
|
|
||||||
LaunchedEffect(playlistId) {
|
LaunchedEffect(playlistId) {
|
||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
|
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
|
LaunchedEffect(playback, playlistId, annotationSessionFolder) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
service.trackBoundaryEvents.collect { event ->
|
service.trackBoundaryEvents.collect { event ->
|
||||||
val snapshot = beatTimesMs.toList()
|
val snapshot = beatTimesMs.toList()
|
||||||
beatTimesMs.clear()
|
beatTimesMs.clear()
|
||||||
viewModel.persistBeatAnnotation(
|
viewModel.persistBeatAnnotation(
|
||||||
playlistId,
|
playlistId,
|
||||||
playlistDisplayName,
|
name,
|
||||||
annotationSessionFolder,
|
annotationSessionFolder,
|
||||||
event,
|
event,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ fun LockstepAppNavHost(
|
|||||||
playlistId = playlistId,
|
playlistId = playlistId,
|
||||||
playback = playback,
|
playback = playback,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = {
|
||||||
|
viewModel.suppressNextLibraryMetadataSync()
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package at.lockstep.player.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -169,6 +170,7 @@ fun NowPlayingRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||||
val collector = remember { RunDataCollector(context) }
|
val collector = remember { RunDataCollector(context) }
|
||||||
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||||
@@ -194,7 +196,7 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
||||||
var currentQueueIndex by remember { mutableIntStateOf(0) }
|
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
var ui by remember {
|
var ui by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -210,14 +212,16 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playlistId) {
|
LaunchedEffect(playlistId) {
|
||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
|
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback) {
|
LaunchedEffect(playback) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
service.uiState.collect { p ->
|
service.uiState.collect { p ->
|
||||||
currentTrackId = p.currentTrackId
|
currentTrackId = p.currentTrackId
|
||||||
currentQueueIndex = p.currentQueueIndex
|
currentPlaylistPosition = p.currentPlaylistPosition
|
||||||
ui =
|
ui =
|
||||||
NowPlayingUiState(
|
NowPlayingUiState(
|
||||||
title = p.title,
|
title = p.title,
|
||||||
@@ -251,12 +255,14 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
|
LaunchedEffect(collectRunData, playback, playlistId) {
|
||||||
if (!collectRunData) return@LaunchedEffect
|
if (!collectRunData) return@LaunchedEffect
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
service.trackBoundaryEvents.collect { event ->
|
service.trackBoundaryEvents.collect { event ->
|
||||||
val snapshot = collector.snapshotAndClear()
|
val snapshot = collector.snapshotAndClear()
|
||||||
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
|
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +285,7 @@ fun NowPlayingRoute(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
title = ui.title,
|
title = ui.title,
|
||||||
artist = ui.artist,
|
artist = ui.artist,
|
||||||
queueIndex = currentQueueIndex,
|
playlistPosition = currentPlaylistPosition,
|
||||||
snapshot = snapshot,
|
snapshot = snapshot,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ fun LibraryScreen(
|
|||||||
|
|
||||||
LaunchedEffect(token) {
|
LaunchedEffect(token) {
|
||||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||||
|
val skipMetadataSync = viewModel.consumeSuppressNextLibraryMetadataSync()
|
||||||
val errors =
|
val errors =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
viewModel.syncJukeboxIfToken(),
|
viewModel.syncJukeboxIfToken(),
|
||||||
viewModel.syncPendingMetadata(),
|
if (skipMetadataSync) null else viewModel.syncPendingMetadata(),
|
||||||
)
|
)
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -50,6 +51,7 @@ fun OnboardingScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -107,16 +107,23 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { viewModel.setCollectRunData(it) },
|
onCheckedChange = { viewModel.setCollectRunData(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.settings_stub_body),
|
text = context.getString(R.string.settings_logout_title),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.settings_logout_spotify_help),
|
text = context.getString(R.string.settings_logout_spotify_help),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
|
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
|
||||||
colors =
|
colors =
|
||||||
ButtonDefaults.buttonColors(
|
ButtonDefaults.buttonColors(
|
||||||
@@ -128,4 +135,5 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,24 +23,67 @@ object BeatAnnotationStorage {
|
|||||||
artist: String,
|
artist: String,
|
||||||
beatTimesMs: List<Long>,
|
beatTimesMs: List<Long>,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val safeName =
|
val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based)
|
||||||
playlistDisplayName
|
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
||||||
.trim()
|
}
|
||||||
.ifBlank { "playlist" }
|
|
||||||
.take(120)
|
/**
|
||||||
val suffix =
|
* 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/.
|
||||||
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
* Overwrites an existing file for the same track index.
|
||||||
val fileName = "${safeName}_$suffix.json"
|
*/
|
||||||
|
fun writeBeatsFile(
|
||||||
|
context: Context,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
contentId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
beatTimesMs: List<Long>,
|
||||||
|
): Uri? {
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
val fileName = "$suffix.json"
|
||||||
|
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
|
||||||
|
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
|
||||||
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes server-fetched beat JSON under Documents/Lockstep/Beats/{playlist_name}/. */
|
||||||
|
fun writeBeatsFileFromJson(
|
||||||
|
context: Context,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
json: JSONObject,
|
||||||
|
): Uri? {
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
val fileName = "$suffix.json"
|
||||||
|
val jsonString = json.toString(2)
|
||||||
|
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
|
||||||
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildAnnotationJson(
|
||||||
|
contentId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
beatTimesMs: List<Long>,
|
||||||
|
): JSONObject {
|
||||||
val sec = beatTimesMs.map { it / 1000.0 }
|
val sec = beatTimesMs.map { it / 1000.0 }
|
||||||
val jsonString =
|
return 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)
|
}
|
||||||
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
}
|
||||||
|
|
||||||
|
private fun trackFileName(
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
): String {
|
||||||
|
val safeName = RunDataStorage.sanitizeFileLabel(playlistDisplayName)
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
return "${safeName}_$suffix.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
||||||
|
|||||||
102
app/src/main/java/at/lockstep/player/util/NtpClient.kt
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
object NtpClient {
|
||||||
|
|
||||||
|
private const val NTP_HOST = "pool.ntp.org"
|
||||||
|
private const val NTP_PORT = 123
|
||||||
|
private const val NTP_PACKET_SIZE = 48
|
||||||
|
private const val TIMEOUT_MS = 5000
|
||||||
|
private const val NTP_EPOCH_OFFSET_SECONDS = 2208988800L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries [NTP_HOST] and returns how many seconds the system clock is ahead of the server.
|
||||||
|
* Positive = local clock is fast; negative = local clock is slow.
|
||||||
|
*/
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun clockOffsetSeconds(): Double {
|
||||||
|
val socket = DatagramSocket()
|
||||||
|
try {
|
||||||
|
socket.soTimeout = TIMEOUT_MS
|
||||||
|
|
||||||
|
val requestBuffer = ByteArray(NTP_PACKET_SIZE)
|
||||||
|
requestBuffer[0] = 0x1B // LI=0, VN=3, mode=client
|
||||||
|
|
||||||
|
val t1Millis = System.currentTimeMillis()
|
||||||
|
writeNtpTimestamp(requestBuffer, 40, t1Millis)
|
||||||
|
|
||||||
|
val address = InetAddress.getByName(NTP_HOST)
|
||||||
|
val requestPacket = DatagramPacket(requestBuffer, requestBuffer.size, address, NTP_PORT)
|
||||||
|
socket.send(requestPacket)
|
||||||
|
|
||||||
|
val responseBuffer = ByteArray(NTP_PACKET_SIZE)
|
||||||
|
val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size)
|
||||||
|
|
||||||
|
val errorRef = AtomicReference<Exception?>(null)
|
||||||
|
val receiveTimeRef = AtomicReference<Long?>(null)
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
socket.receive(responsePacket)
|
||||||
|
receiveTimeRef.set(System.currentTimeMillis())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorRef.set(e)
|
||||||
|
} finally {
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
|
||||||
|
if (!latch.await((TIMEOUT_MS + 1000).toLong(), TimeUnit.MILLISECONDS)) {
|
||||||
|
throw SocketTimeoutException("NTP request timed out")
|
||||||
|
}
|
||||||
|
errorRef.get()?.let { throw it }
|
||||||
|
|
||||||
|
val t4Millis = receiveTimeRef.get()
|
||||||
|
?: throw IllegalStateException("NTP response received without timestamp")
|
||||||
|
|
||||||
|
val t1 = t1Millis / 1000.0
|
||||||
|
val t2 = ntpBytesToSeconds(responseBuffer, 32)
|
||||||
|
val t3 = ntpBytesToSeconds(responseBuffer, 40)
|
||||||
|
val t4 = t4Millis / 1000.0
|
||||||
|
|
||||||
|
return ((t1 - t2) + (t4 - t3)) / 2.0
|
||||||
|
} finally {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntpBytesToSeconds(buffer: ByteArray, offset: Int): Double {
|
||||||
|
val seconds = readUint32(buffer, offset)
|
||||||
|
val fraction = readUint32(buffer, offset + 4)
|
||||||
|
return (seconds - NTP_EPOCH_OFFSET_SECONDS) + fraction / 4294967296.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeNtpTimestamp(buffer: ByteArray, offset: Int, millis: Long) {
|
||||||
|
val unixSeconds = millis / 1000.0
|
||||||
|
val ntpSeconds = (unixSeconds + NTP_EPOCH_OFFSET_SECONDS).toLong()
|
||||||
|
val fraction = ((unixSeconds - unixSeconds.toLong()) * 4294967296.0).toLong()
|
||||||
|
writeUint32(buffer, offset, ntpSeconds)
|
||||||
|
writeUint32(buffer, offset + 4, fraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUint32(buffer: ByteArray, offset: Int): Long =
|
||||||
|
((buffer[offset].toLong() and 0xFF) shl 24) or
|
||||||
|
((buffer[offset + 1].toLong() and 0xFF) shl 16) or
|
||||||
|
((buffer[offset + 2].toLong() and 0xFF) shl 8) or
|
||||||
|
(buffer[offset + 3].toLong() and 0xFF)
|
||||||
|
|
||||||
|
private fun writeUint32(buffer: ByteArray, offset: Int, value: Long) {
|
||||||
|
buffer[offset] = ((value shr 24) and 0xFF).toByte()
|
||||||
|
buffer[offset + 1] = ((value shr 16) and 0xFF).toByte()
|
||||||
|
buffer[offset + 2] = ((value shr 8) and 0xFF).toByte()
|
||||||
|
buffer[offset + 3] = (value and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.util
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -16,15 +17,27 @@ import java.util.Locale
|
|||||||
|
|
||||||
object RunDataStorage {
|
object RunDataStorage {
|
||||||
private const val APP_DIR = "Lockstep"
|
private const val APP_DIR = "Lockstep"
|
||||||
|
private const val BEATS_DIR = "Beats"
|
||||||
|
|
||||||
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
||||||
fun newRunSessionFolderName(): String =
|
fun newRunSessionFolderName(): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
||||||
|
|
||||||
|
fun sanitizeFileLabel(name: String): String =
|
||||||
|
name
|
||||||
|
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
.trim()
|
||||||
|
.ifBlank { "playlist" }
|
||||||
|
.take(120)
|
||||||
|
|
||||||
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
||||||
fun documentsRelativePath(runSessionFolder: String): String =
|
fun documentsRelativePath(runSessionFolder: String): String =
|
||||||
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
||||||
|
|
||||||
|
/** Public path segment: `Documents/Lockstep/Beats/{playlist_name}/`. */
|
||||||
|
fun beatsPlaylistRelativePath(playlistDisplayName: String): String =
|
||||||
|
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$BEATS_DIR/${sanitizeFileLabel(playlistDisplayName)}"
|
||||||
|
|
||||||
fun writeRunDataFile(
|
fun writeRunDataFile(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
runSessionFolder: String,
|
||||||
@@ -37,12 +50,7 @@ object RunDataStorage {
|
|||||||
): Uri? {
|
): Uri? {
|
||||||
if (snapshot.isEmpty()) return null
|
if (snapshot.isEmpty()) return null
|
||||||
|
|
||||||
val safeName =
|
val safeName = sanitizeFileLabel(playlistDisplayName)
|
||||||
playlistDisplayName
|
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
|
||||||
.trim()
|
|
||||||
.ifBlank { "playlist" }
|
|
||||||
.take(120)
|
|
||||||
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
val fileName = "${safeName}_$suffix.json"
|
val fileName = "${safeName}_$suffix.json"
|
||||||
|
|
||||||
@@ -67,11 +75,25 @@ object RunDataStorage {
|
|||||||
sessionFolder: String,
|
sessionFolder: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
|
): Uri? =
|
||||||
|
writeOrReplacePublicJsonFile(
|
||||||
|
context,
|
||||||
|
documentsRelativePath(sessionFolder),
|
||||||
|
fileName,
|
||||||
|
jsonString,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Writes or overwrites [jsonString] at public Documents/{relativePath}/{fileName}. */
|
||||||
|
fun writeOrReplacePublicJsonFile(
|
||||||
|
context: Context,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
): Uri? =
|
): Uri? =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
writeViaMediaStore(context, sessionFolder, fileName, jsonString)
|
writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
|
||||||
} else {
|
} else {
|
||||||
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString)
|
writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
||||||
@@ -135,22 +157,57 @@ object RunDataStorage {
|
|||||||
return array
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeViaMediaStore(
|
private fun writeOrReplaceViaMediaStore(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val resolver = context.applicationContext.contentResolver
|
val resolver = context.applicationContext.contentResolver
|
||||||
val relativePath = documentsRelativePath(runSessionFolder)
|
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
|
val existingUri = findMediaStoreFileUri(resolver, collection, relativePath, fileName)
|
||||||
|
if (existingUri != null) {
|
||||||
|
resolver.openOutputStream(existingUri, "wt")?.use { stream ->
|
||||||
|
stream.write(jsonString.toByteArray(Charsets.UTF_8))
|
||||||
|
} ?: return null
|
||||||
|
return existingUri
|
||||||
|
}
|
||||||
|
return insertViaMediaStore(resolver, collection, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findMediaStoreFileUri(
|
||||||
|
resolver: android.content.ContentResolver,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
): Uri? {
|
||||||
|
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||||
|
val selection =
|
||||||
|
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?"
|
||||||
|
val selectionArgs = arrayOf(relativePathWithTrailingSlash(relativePath), fileName)
|
||||||
|
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||||
|
return ContentUris.withAppendedId(collection, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertViaMediaStore(
|
||||||
|
resolver: android.content.ContentResolver,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? {
|
||||||
val pending =
|
val pending =
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePathWithTrailingSlash(relativePath))
|
||||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
}
|
}
|
||||||
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
||||||
val uri = resolver.insert(collection, pending) ?: return null
|
val uri = resolver.insert(collection, pending) ?: return null
|
||||||
try {
|
try {
|
||||||
resolver.openOutputStream(uri)?.use { stream ->
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
@@ -168,17 +225,23 @@ object RunDataStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MediaStore stores directory relative paths with a trailing separator. */
|
||||||
|
private fun relativePathWithTrailingSlash(relativePath: String): String =
|
||||||
|
if (relativePath.endsWith("/")) relativePath else "$relativePath/"
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun writeViaPublicDocumentsDir(
|
private fun writeOrReplaceViaPublicDocumentsDir(
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val dir =
|
val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
|
||||||
File(
|
val lockstepPrefix = "$APP_DIR/"
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
|
val subPath =
|
||||||
"$APP_DIR/$runSessionFolder",
|
relativePath.removePrefix("${Environment.DIRECTORY_DOCUMENTS}/")
|
||||||
)
|
.removePrefix("${Environment.DIRECTORY_DOCUMENTS}${File.separator}")
|
||||||
|
.removePrefix(lockstepPrefix)
|
||||||
|
val dir = File(documentsRoot, "$APP_DIR/$subPath")
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -1,21 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Lockstep</string>
|
<string name="app_name">Pasada</string>
|
||||||
<string name="cd_previous_track">Previous track</string>
|
<string name="cd_previous_track">Previous track</string>
|
||||||
<string name="cd_play_pause">Play or pause</string>
|
<string name="cd_play_pause">Play or pause</string>
|
||||||
<string name="cd_next_track">Next track</string>
|
<string name="cd_next_track">Next track</string>
|
||||||
|
|
||||||
<string name="notification_channel_playback_name">Playback</string>
|
<string name="notification_channel_playback_name">Playback</string>
|
||||||
<string name="notification_channel_playback_description">Now playing controls while Lockstep runs</string>
|
<string name="notification_channel_playback_description">Now playing controls while Pasada runs</string>
|
||||||
<string name="notification_prev">Previous</string>
|
<string name="notification_prev">Previous</string>
|
||||||
<string name="notification_next">Next</string>
|
<string name="notification_next">Next</string>
|
||||||
<string name="notification_play_pause">Play or pause</string>
|
<string name="notification_play_pause">Play or pause</string>
|
||||||
<string name="notification_loading_playlist">Loading playlist…</string>
|
<string name="notification_loading_playlist">Loading playlist…</string>
|
||||||
|
|
||||||
<string name="onboarding_title">Welcome to Lockstep</string>
|
<string name="onboarding_title">Welcome to Pasada</string>
|
||||||
<string name="onboarding_notifications_body">Lockstep shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
<string name="onboarding_notifications_body">Pasada shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
||||||
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
|
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
|
||||||
<string name="onboarding_spotify_body">Sign in with Spotify via the Lockstep web login. When your browser returns to this app, your access token is stored locally.</string>
|
<string name="onboarding_spotify_body">Sign in with Spotify via the Pasada web login. When your browser returns to this app, your access token is stored locally.</string>
|
||||||
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
|
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
|
||||||
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
|
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
|
||||||
<string name="onboarding_continue_signed_in">Continue</string>
|
<string name="onboarding_continue_signed_in">Continue</string>
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
|
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
|
||||||
|
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_stub_body">More controls will land here in a later milestone.</string>
|
<string name="settings_logout_title">Logout</string>
|
||||||
<string name="settings_logout_spotify">Sign out of Spotify</string>
|
<string name="settings_logout_spotify">Sign out of Spotify</string>
|
||||||
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
|
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
|
||||||
<string name="settings_annotation_mode">Annotation mode</string>
|
<string name="settings_annotation_mode">Annotation mode</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_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/Lockstep/ 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="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>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
||||||
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
||||||
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
|
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
|
||||||
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See LockstepPairing logs.</string>
|
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See PasadaPairing logs.</string>
|
||||||
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
|
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
|
||||||
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
|
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
|
||||||
<string name="pairing_unknown_track">(removed or unknown track)</string>
|
<string name="pairing_unknown_track">(removed or unknown track)</string>
|
||||||
|
|||||||
66
media/shoe_logo4.svg
Normal file
|
After Width: | Height: | Size: 51 KiB |
324
plans/runbuddy-mode.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
---
|
||||||
|
name: Runbuddy Mode Plan
|
||||||
|
overview: "Add 1:1 RunBuddy sessions: leader creates a public session on the server, follower picks it from an in-app billboard, and both stay in sync via WebSocket events timestamped with NTP-corrected wall clock. Both devices use the same Spotify playlist and local MP3 pairings."
|
||||||
|
todos:
|
||||||
|
- id: server-schema-rest
|
||||||
|
content: Add runbuddy_sessions/participants tables + REST endpoints (create, billboard, join, end, leave) to lockstep-2-api
|
||||||
|
status: pending
|
||||||
|
- id: server-websocket
|
||||||
|
content: Add WebSocket room relay with token auth, snapshot persistence, and leader→follower fan-out
|
||||||
|
status: pending
|
||||||
|
- id: android-clients
|
||||||
|
content: Implement RunBuddyApiClient, RunBuddyWebSocketClient (OkHttp), RunBuddyClock wrapping NtpClient
|
||||||
|
status: pending
|
||||||
|
- id: sync-protocol
|
||||||
|
content: Define JSON event schema and follower drift correction math in RunBuddyCoordinator
|
||||||
|
status: pending
|
||||||
|
- id: playback-hooks
|
||||||
|
content: Extend PlaybackService with attachRunBuddy, applyRemoteTransport, leader emit hooks on transport changes
|
||||||
|
status: pending
|
||||||
|
- id: prejoin-validation
|
||||||
|
content: Follower pairing validation against session playlistId + pairedTrackCount before join
|
||||||
|
status: pending
|
||||||
|
- id: ui-flows
|
||||||
|
content: Add RunBuddy billboard screen, leader start/waiting UI, Now Playing role banner and follower control lockout
|
||||||
|
status: pending
|
||||||
|
- id: e2e-test
|
||||||
|
content: "Two-device test: create/join, play/pause/seek/skip/track-change, verify <50ms perceived drift while running"
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# RunBuddy Mode — Implementation Plan
|
||||||
|
|
||||||
|
## Scope (confirmed)
|
||||||
|
|
||||||
|
- **1 leader + 1 follower** per session (session locks when follower joins)
|
||||||
|
- **Same Spotify playlist + same local MP3 pairings** on both devices
|
||||||
|
- **Leader is source of truth** for playback transport and position
|
||||||
|
- **Position sync target:** millisecond-level via [`NtpClient.kt`](app/src/main/java/at/lockstep/player/util/NtpClient.kt) + timestamped realtime events
|
||||||
|
|
||||||
|
## Current baseline
|
||||||
|
|
||||||
|
| Layer | Today | Gap |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Playback | [`PlaybackService`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) owns queue, play/pause/seek/skip locally | No remote command ingress |
|
||||||
|
| Networking | REST only ([`MetadataSyncClient`](app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt), jukebox) | No sessions, no WebSocket |
|
||||||
|
| Server | Flask monolith [`lockstep-2-api/api.py`](lockstep-2-api/api.py) | No session model, no realtime |
|
||||||
|
| Clock | `NtpClient` exists but unused | Wire into session lifecycle |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Leader as LeaderApp
|
||||||
|
participant Server as api.lockstep.at
|
||||||
|
participant Follower as FollowerApp
|
||||||
|
participant NTP as pool.ntp.org
|
||||||
|
|
||||||
|
Leader->>NTP: NtpClient (offset)
|
||||||
|
Leader->>Server: POST /runbuddy/sessions
|
||||||
|
Server-->>Leader: sessionId, joinCode, wsToken
|
||||||
|
Leader->>Server: WS connect (leader)
|
||||||
|
Follower->>Server: GET /runbuddy/sessions (billboard)
|
||||||
|
Follower->>Server: POST /runbuddy/sessions/{id}/join
|
||||||
|
Follower->>NTP: NtpClient (offset)
|
||||||
|
Follower->>Server: WS connect (follower)
|
||||||
|
Leader->>Server: transport + position events (correctedMs)
|
||||||
|
Server->>Follower: fan-out events
|
||||||
|
Follower->>Follower: apply to PlaybackService
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Server — session model + billboard + WebSocket
|
||||||
|
|
||||||
|
Extend [`lockstep-2-api/api.py`](lockstep-2-api/api.py) (or split into `runbuddy.py` imported by `api.py` to keep the monolith manageable).
|
||||||
|
|
||||||
|
### SQLite tables
|
||||||
|
|
||||||
|
**`runbuddy_sessions`**
|
||||||
|
- `id` (UUID, PK)
|
||||||
|
- `host_spotify_user_id`
|
||||||
|
- `playlist_id`, `playlist_name`
|
||||||
|
- `paired_track_count` (leader-reported, for follower pre-check)
|
||||||
|
- `status` (`waiting` | `active` | `ended`)
|
||||||
|
- `created_at`, `expires_at` (TTL, e.g. 2h idle)
|
||||||
|
|
||||||
|
**`runbuddy_participants`**
|
||||||
|
- `session_id`, `role` (`leader` | `follower`)
|
||||||
|
- `spotify_user_id`, `joined_at`
|
||||||
|
- Unique constraint: one follower per session
|
||||||
|
|
||||||
|
**`runbuddy_ws_tokens`**
|
||||||
|
- Short-lived join token per participant (avoid putting Spotify bearer in WS query string long-term)
|
||||||
|
|
||||||
|
### REST endpoints (all `@require_auth` with existing Bearer Spotify token)
|
||||||
|
|
||||||
|
| Method | Route | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| `POST` | `/runbuddy/sessions` | Leader creates session. Body: `{ playlistId, playlistName, pairedTrackCount }`. Returns `{ sessionId, joinCode, wsUrl, wsToken, snapshot }`. |
|
||||||
|
| `GET` | `/runbuddy/sessions` | **Billboard**: list `status=waiting` public sessions (host display name, playlist name, paired count, created_at). |
|
||||||
|
| `GET` | `/runbuddy/sessions/{id}` | Session snapshot for late join / reconnect. |
|
||||||
|
| `POST` | `/runbuddy/sessions/{id}/join` | Follower joins (409 if full). Returns `{ wsUrl, wsToken, snapshot }`. |
|
||||||
|
| `POST` | `/runbuddy/sessions/{id}/end` | Leader ends session. |
|
||||||
|
| `POST` | `/runbuddy/sessions/{id}/leave` | Follower leaves. |
|
||||||
|
|
||||||
|
**Snapshot** (stored server-side, updated by leader over WS) includes current transport state:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"playlistId": "...",
|
||||||
|
"playlistPosition": 3,
|
||||||
|
"trackId": "spotify:track:...",
|
||||||
|
"positionMs": 45123,
|
||||||
|
"isPlaying": true,
|
||||||
|
"correctedMs": 1717171717171
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket channel
|
||||||
|
|
||||||
|
Add a realtime layer to the Flask deployment. Practical options:
|
||||||
|
|
||||||
|
- **Recommended for MVP:** [`flask-sock`](https://github.com/miguelgrinberg/flask-sock) (simple, works with existing Flask app) or a small **sidecar ASGI service** if production WSGI cannot hold long-lived connections
|
||||||
|
- Route: `GET /runbuddy/ws?sessionId=&token=`
|
||||||
|
- In-memory room registry keyed by `sessionId` (acceptable for 1:1 MVP; document Redis upgrade path if scaling)
|
||||||
|
|
||||||
|
**Server responsibilities:**
|
||||||
|
- Authenticate token → session + role
|
||||||
|
- Relay JSON messages leader → follower (follower messages limited to `ping`, `leave`, optional `ready`)
|
||||||
|
- Persist latest snapshot on leader `state` / `position` messages
|
||||||
|
- Close room and set `status=ended` on leader disconnect or explicit end
|
||||||
|
|
||||||
|
### Operational notes
|
||||||
|
|
||||||
|
- Add session TTL cleanup cron or lazy expiry on billboard GET
|
||||||
|
- Rate-limit session creation (already flagged in [`BUGS.md`](BUGS.md))
|
||||||
|
- WebSocket requires production config change (proxy timeout, upgrade headers) — document in deploy notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sync protocol — NTP + event timestamps
|
||||||
|
|
||||||
|
### Shared clock helper (new Android class)
|
||||||
|
|
||||||
|
[`RunBuddyClock`](app/src/main/java/at/lockstep/player/runbuddy/RunBuddyClock.kt) (proposed):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// offsetMs = local ahead of NTP (from NtpClient)
|
||||||
|
fun correctedNowMs(): Long = System.currentTimeMillis() - offsetMs
|
||||||
|
```
|
||||||
|
|
||||||
|
- Call `NtpClient.clockOffsetSeconds()` on **session join** (background thread) and optionally **refresh every ~5 min** during active session
|
||||||
|
- Both peers use the same reference (NTP), not server wall clock — satisfies your requirement and avoids adding a server time endpoint for MVP
|
||||||
|
|
||||||
|
### Event types (WebSocket JSON)
|
||||||
|
|
||||||
|
| Type | Sender | Fields | Purpose |
|
||||||
|
|------|--------|--------|---------|
|
||||||
|
| `transport` | leader | `action`: `play`/`pause`/`seek`/`skip_next`/`skip_prev`/`track_change`, `correctedMs`, `positionMs`, `playlistPosition`, `trackId` | Immediate state changes |
|
||||||
|
| `position` | leader | `correctedMs`, `positionMs`, `isPlaying`, `playlistPosition`, `trackId` | Heartbeat every **250ms** (match existing [`UPDATE_INTERVAL_MS`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt)) |
|
||||||
|
| `snapshot` | server | full snapshot | On follower connect / reconnect |
|
||||||
|
| `session_end` | server | reason | Tear down |
|
||||||
|
|
||||||
|
All timestamps in events use **`correctedMs`** (NTP-aligned epoch millis).
|
||||||
|
|
||||||
|
### Follower position correction
|
||||||
|
|
||||||
|
On each `position` or `transport` event:
|
||||||
|
|
||||||
|
```
|
||||||
|
targetMs = event.positionMs + (event.isPlaying ? followerClock.correctedNowMs() - event.correctedMs : 0)
|
||||||
|
drift = targetMs - localEngine.getCurrentPositionMs()
|
||||||
|
if |drift| > 30ms → engine.seekTo(targetMs)
|
||||||
|
if track mismatch → publishCurrentTrack by playlistPosition/trackId first, then seek
|
||||||
|
```
|
||||||
|
|
||||||
|
- **30ms threshold** is a starting point; tune with real devices
|
||||||
|
- When paused, do not extrapolate — hold `positionMs` exactly
|
||||||
|
- Ignore heartbeats while a transport event is being applied (short debounce)
|
||||||
|
|
||||||
|
### Leader behavior
|
||||||
|
|
||||||
|
- Local UI actions (`requestTogglePause`, `requestSeek`, skip) **also emit** corresponding `transport` events before/after applying locally
|
||||||
|
- Position heartbeats read `getPlaybackPositionMs()` + `RunBuddyClock.correctedNowMs()`
|
||||||
|
- Leader local controls remain enabled; follower controls **disabled or no-op** (show "following leader" UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Android — new modules and PlaybackService integration
|
||||||
|
|
||||||
|
### New packages / files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `data/RunBuddyApiClient.kt` | REST: create, list, join, end, leave |
|
||||||
|
| `runbuddy/RunBuddyWebSocketClient.kt` | OkHttp WebSocket (already on classpath) with reconnect + backoff |
|
||||||
|
| `runbuddy/RunBuddyClock.kt` | NTP offset cache |
|
||||||
|
| `runbuddy/RunBuddyCoordinator.kt` | Session lifecycle, role, event encode/decode, snapshot apply |
|
||||||
|
| `runbuddy/RunBuddySessionState.kt` | `Idle`, `Leading(sessionId)`, `Following(sessionId)` |
|
||||||
|
|
||||||
|
Wire clients from [`LockstepApplication`](app/src/main/java/at/lockstep/player/LockstepApplication.kt) (shared `OkHttpClient`).
|
||||||
|
|
||||||
|
### PlaybackService changes ([`PlaybackService.kt`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt))
|
||||||
|
|
||||||
|
Add a **`RunBuddyCoordinator` reference** (injected via new binder methods or constructor set from NavHost when session starts):
|
||||||
|
|
||||||
|
**New public API:**
|
||||||
|
- `attachRunBuddy(coordinator: RunBuddyCoordinator)`
|
||||||
|
- `detachRunBuddy()`
|
||||||
|
- `applyRemoteTransport(...)` — internal mirror of play/pause/seek/skip/track change **without** re-emitting leader events
|
||||||
|
- `isRunBuddyFollower(): Boolean` — UI uses this to disable controls
|
||||||
|
|
||||||
|
**Hook points (existing methods):**
|
||||||
|
|
||||||
|
```403:452:app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
|
||||||
|
private fun setPlaying(playing: Boolean) { ... }
|
||||||
|
private fun skipDelta(delta: Int) { ... }
|
||||||
|
fun requestSeek(fraction: Float) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Leader path: after local mutation, notify coordinator → WS send
|
||||||
|
- Follower path: coordinator calls `applyRemoteTransport` instead of user `request*` methods
|
||||||
|
|
||||||
|
**Track identity:** sync on `currentPlaylistPosition` + `currentTrackId` (not `currentQueueIndex`) to stay stable across paired-only queue indexing — aligns with existing [`PlaybackUiState`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) fields and avoids the unpaired-track index bug called out in [`BUGS.md`](BUGS.md).
|
||||||
|
|
||||||
|
### Pre-join validation (follower)
|
||||||
|
|
||||||
|
Before `POST .../join`:
|
||||||
|
1. User must be signed in (existing Spotify token)
|
||||||
|
2. Local `pairingDao.countPaired(playlistId) >= session.pairedTrackCount` (and ideally equal)
|
||||||
|
3. Optionally verify each Spotify track id in leader snapshot queue is paired locally — fail fast with clear error if not
|
||||||
|
|
||||||
|
### ViewModel ([`LockstepViewModel.kt`](app/src/main/java/at/lockstep/player/LockstepViewModel.kt))
|
||||||
|
|
||||||
|
Add thin session orchestration (not playback logic):
|
||||||
|
- `createRunBuddySession(playlistId)`
|
||||||
|
- `listPublicSessions()`
|
||||||
|
- `joinRunBuddySession(sessionId)`
|
||||||
|
- `endRunBuddySession()`
|
||||||
|
- Expose `runBuddyState: StateFlow<RunBuddySessionState>` for UI
|
||||||
|
|
||||||
|
Keep playback sync inside `PlaybackService` + coordinator (consistent with [`DESIGN.md`](DESIGN.md) foreground-service ownership).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI flows
|
||||||
|
|
||||||
|
### Navigation ([`Routes.kt`](app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt))
|
||||||
|
|
||||||
|
Add routes:
|
||||||
|
- `runbuddy/billboard` — public session list
|
||||||
|
- Reuse `nowPlaying/{playlistId}` for both roles once connected
|
||||||
|
|
||||||
|
### Leader flow
|
||||||
|
|
||||||
|
1. Library → select playlist (must be fully paired)
|
||||||
|
2. **"Start RunBuddy"** action (new button on Library row or playlist detail)
|
||||||
|
3. Create session → show join code + "Waiting for buddy…"
|
||||||
|
4. On follower join → navigate to Now Playing, start playlist via existing `startPlaylistPlayback()`
|
||||||
|
5. Coordinator attaches as **leader**; heartbeats begin
|
||||||
|
|
||||||
|
### Follower flow
|
||||||
|
|
||||||
|
1. New entry point: **RunBuddy** from Library top bar or Settings
|
||||||
|
2. [`RunBuddyBillboardScreen`](app/src/main/java/at/lockstep/player/ui/runbuddy/RunBuddyBillboardScreen.kt) — poll or pull-to-refresh `GET /runbuddy/sessions`
|
||||||
|
3. Tap session → validate pairings → join → navigate to Now Playing + start same playlist
|
||||||
|
4. Coordinator attaches as **follower**; apply snapshot immediately
|
||||||
|
|
||||||
|
### Now Playing UI ([`NowPlayingScreen.kt`](app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt))
|
||||||
|
|
||||||
|
- Banner: "Leading RunBuddy" / "Following [host name]"
|
||||||
|
- Follower: disable play/pause/skip/seek slider (or show read-only progress)
|
||||||
|
- Leader: **End session** button
|
||||||
|
- Show sync status indicator (clock synced / WS connected / drift ms for debug builds)
|
||||||
|
|
||||||
|
### Settings ([`SettingsScreen.kt`](app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt))
|
||||||
|
|
||||||
|
Optional toggle: **"Enable RunBuddy mode"** (feature flag) — can defer if always-on is fine for MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Suggested implementation phases
|
||||||
|
|
||||||
|
### Phase A — Server foundation
|
||||||
|
- DB migration + REST CRUD + billboard list
|
||||||
|
- Manual test with curl/Postman
|
||||||
|
|
||||||
|
### Phase B — WebSocket relay
|
||||||
|
- Token auth, leader→follower fan-out, snapshot persistence
|
||||||
|
- Test with `websocat` or a minimal HTML client
|
||||||
|
|
||||||
|
### Phase C — Android clients
|
||||||
|
- `RunBuddyApiClient`, `RunBuddyWebSocketClient`, `RunBuddyClock`
|
||||||
|
- Unit-test event parsing + follower drift math (pure Kotlin)
|
||||||
|
|
||||||
|
### Phase D — Playback integration
|
||||||
|
- `RunBuddyCoordinator` + `PlaybackService` hooks
|
||||||
|
- End-to-end two-emulator or two-device test
|
||||||
|
|
||||||
|
### Phase E — UI + polish
|
||||||
|
- Billboard + leader start/waiting screens
|
||||||
|
- Reconnect (follower re-joins, applies snapshot, resumes heartbeats)
|
||||||
|
- Session expiry, error toasts, 409 when session full
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| ExoPlayer seek latency causes drift | Periodic 250ms heartbeats + 30ms threshold; pause extrapolation |
|
||||||
|
| NTP blocked on mobile network | Fail gracefully; show "clock sync failed" and block join |
|
||||||
|
| Flask WS in production | Verify reverse-proxy WS upgrade; fallback sidecar if needed |
|
||||||
|
| Unpaired-track index bug | Sync on `playlistPosition`/`trackId`; fix BUGS.md issue in parallel |
|
||||||
|
| Leader backgrounded | Foreground service already running; WS kept alive in service process |
|
||||||
|
| Pairing mismatch | Pre-join validation against `pairedTrackCount` + track ids |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of scope (MVP)
|
||||||
|
|
||||||
|
- Group runs (1:N)
|
||||||
|
- Cross-playlist or streaming without local MP3s
|
||||||
|
- Syncing run-data sensor JSON over WS (only playback)
|
||||||
|
- Server-side beat/metadata sharing per session (reuse existing per-user metadata APIs later)
|
||||||
53
scripts/generate_launcher_icons.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Downscale launcher icon PNGs from mipmap-xxxhdpi masters to lower densities."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RES = ROOT / "app" / "src" / "main" / "res"
|
||||||
|
SOURCE_FOLDER = "mipmap-xxxhdpi"
|
||||||
|
|
||||||
|
SOURCES = {
|
||||||
|
"ic_launcher.png": {
|
||||||
|
"mipmap-xxxhdpi": 192,
|
||||||
|
"mipmap-xxhdpi": 144,
|
||||||
|
"mipmap-xhdpi": 96,
|
||||||
|
"mipmap-hdpi": 72,
|
||||||
|
"mipmap-mdpi": 48,
|
||||||
|
},
|
||||||
|
"ic_launcher_foreground.png": {
|
||||||
|
"mipmap-xxxhdpi": 432,
|
||||||
|
"mipmap-xxhdpi": 324,
|
||||||
|
"mipmap-xhdpi": 216,
|
||||||
|
"mipmap-hdpi": 162,
|
||||||
|
"mipmap-mdpi": 108,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
for filename, sizes in SOURCES.items():
|
||||||
|
source_path = RES / SOURCE_FOLDER / filename
|
||||||
|
source = Image.open(source_path)
|
||||||
|
expected = sizes[SOURCE_FOLDER]
|
||||||
|
if source.size != (expected, expected):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{source_path} is {source.size[0]}x{source.size[1]}, expected {expected}x{expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for folder, size in sizes.items():
|
||||||
|
if folder == SOURCE_FOLDER:
|
||||||
|
continue
|
||||||
|
out_dir = RES / folder
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
resized = source.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(out_dir / filename)
|
||||||
|
print(f"Wrote {folder}/{filename} ({size}x{size})")
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||