16 Commits

Author SHA1 Message Date
ab07086fcb fix: allow opening playlist with api 401 error and others 2026-06-11 17:37:19 +02:00
096c676ce2 feat: LibPasada player using stretcher 2026-06-03 18:13:55 +02:00
922cc5d82a ui: update icon 2026-05-31 13:01:13 +02:00
88db4ad18d fix: indexing of beat annotations after unpaired tracks 2026-05-31 12:10:02 +02:00
a10102c10a fix: avoid duplicate beat metadata fetching from api 2026-05-31 12:02:28 +02:00
19bbccf244 fix: avoid syncing beat metadata twice when returning from Now Playing -> Library 2026-05-31 11:53:20 +02:00
f7c5b932e9 feat: fetch beat metadata from api 2026-05-31 11:41:06 +02:00
633afd115d ui: re-brand to Pasada 2026-05-31 10:22:58 +02:00
12adfbc960 feat: add canonical beat pattern TYPE_BEATS, not synced to server 2026-05-31 10:21:31 +02:00
b332c7b2f3 ui: shoe logo, now bent 2026-05-31 09:51:17 +02:00
306ad83d12 fix: stop playback if app is stopped 2026-05-30 20:21:50 +02:00
9d161bd2a9 ui: fix top padding in Onboarding screen 2026-05-30 20:14:32 +02:00
ad2dc18f00 ui: relayout settings logout 2026-05-30 20:11:31 +02:00
8d1b762b78 feat: fix annotation button size 2026-05-30 19:57:41 +02:00
e936dee142 feat: also sync beat annotations to api, store beat annotations in Documents 2026-05-30 19:55:42 +02:00
45c4980c05 feat: upload collected run data to api 2026-05-30 19:47:58 +02:00
41 changed files with 1066 additions and 122 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ captures/
*.apk *.apk
*.ap_ *.ap_
*.aab *.aab
# David
app/src/main/jniLibs

View File

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

View File

@@ -2,6 +2,73 @@
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**. Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
## UI screens
- Onboarding: explains why notifications are necessary, implements login
- Library: shows the playlists of the user
- Annotation: user presses a button to annotate the beat
- Now Playing: player showing an individual track, with pause/previous/next buttons
- Collection mode: app records sensor data during a run
- Settings: allows to enable modes above, logout button
## 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.

View File

@@ -11,6 +11,8 @@ Android prototype: music playback adapts to running pace (accelerometer + native
**App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/). **App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/).
**API:** [`lockstep-2-api/`](lockstep-2-api/) — `api.py` contains the Python API that is deployed on the server behind api.lockstep.at
Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studios **jbr** under `/Applications/…` or `/c/Program Files/…`. Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studios **jbr** under `/Applications/…` or `/c/Program Files/…`.
Submodule: [`jukebox/`](jukebox/). Submodule: [`jukebox/`](jukebox/).

View File

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

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.os.Build import android.os.Build
import at.lockstep.jukebox.Jukebox import at.lockstep.jukebox.Jukebox
import at.lockstep.jukebox.PlaylistRepository import at.lockstep.jukebox.PlaylistRepository
import at.lockstep.player.data.MetadataSyncClient
import at.lockstep.player.data.db.AppDatabase import at.lockstep.player.data.db.AppDatabase
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class LockstepApplication : Application() { class LockstepApplication : Application() {
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.getInstance(this) } val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
val metadataSyncClient: MetadataSyncClient by lazy {
MetadataSyncClient(
OkHttpClient(),
BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/'),
)
}
val playlistRepository: PlaylistRepository by lazy { val playlistRepository: PlaylistRepository by lazy {
Jukebox.playlistRepository( Jukebox.playlistRepository(
this, this,

View File

@@ -7,7 +7,9 @@ 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.TrackPairingEntity import at.lockstep.player.data.db.TrackPairingEntity
import at.lockstep.player.util.AudioUriValidator import at.lockstep.player.util.AudioUriValidator
import at.lockstep.player.playback.TrackBoundaryEvent import at.lockstep.player.playback.TrackBoundaryEvent
@@ -28,6 +30,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -38,6 +42,7 @@ class LockstepViewModel(
companion object { companion object {
private const val TAG = "LockstepPairing" private const val TAG = "LockstepPairing"
private const val METADATA_TAG = "LockstepMetadata"
/** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */ /** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>() private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
@@ -46,6 +51,7 @@ class LockstepViewModel(
} }
private val prefs = UserPreferencesRepository(application) private val prefs = UserPreferencesRepository(application)
private val pairingDao get() = app.database.pairingDao() private val pairingDao get() = app.database.pairingDao()
private val fileMetadataDao get() = app.database.fileMetadataDao()
val onboardingComplete: StateFlow<Boolean> = val onboardingComplete: StateFlow<Boolean> =
prefs.onboardingComplete.stateIn( prefs.onboardingComplete.stateIn(
@@ -162,12 +168,14 @@ class LockstepViewModel(
} }
/** /**
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described * Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
* by [event]. 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,
playlistDisplayName: String, playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent, event: TrackBoundaryEvent,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
) { ) {
@@ -178,15 +186,75 @@ 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 appContext = getApplication<Application>()
val annotationUri =
BeatAnnotationStorage.writeAnnotationsFile( BeatAnnotationStorage.writeAnnotationsFile(
context = getApplication(), context = appContext,
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
recordMetadataEntry(
fileUri = annotationUri.toString(),
trackId = event.trackId,
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,
synced = beats,
lastSyncedAt = if (beats) now else null,
)
val id = fileMetadataDao.insert(entry)
if (!beats) {
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)
} }
} }
@@ -207,11 +275,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex, trackQueueIndex0Based = event.playlistPosition,
trackId = event.trackId,
metaContentUri = meta, metaContentUri = meta,
title = event.title, title = event.title,
artist = event.artist, artist = event.artist,
@@ -228,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()) {
@@ -237,11 +305,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId) val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex, trackQueueIndex0Based = playlistPosition,
trackId = trackId,
metaContentUri = meta, metaContentUri = meta,
title = title, title = title,
artist = artist, artist = artist,
@@ -250,6 +318,218 @@ class LockstepViewModel(
} }
} }
private suspend fun writeRunDataAndRecordMetadata(
runSessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
trackId: String,
metaContentUri: String,
title: String,
artist: String,
snapshot: RunTrackDataSnapshot,
) {
val uri =
RunDataStorage.writeRunDataFile(
context = getApplication(),
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = trackQueueIndex0Based,
metaContentUri = metaContentUri,
title = title,
artist = artist,
snapshot = snapshot,
) ?: return
val entry =
FileMetadataEntity(
fileUri = uri.toString(),
trackId = trackId,
type = FileMetadataEntity.TYPE_COLLECTION,
version = BuildConfig.VERSION_CODE,
)
val id = fileMetadataDao.insert(entry)
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? {
val token = spotifyAccessToken.value
if (token.isNullOrBlank()) {
return null
}
return withContext(Dispatchers.IO) {
val pending = fileMetadataDao.listUnsynced()
if (pending.isEmpty()) {
return@withContext null
}
val failures = pending.count { entry -> !syncMetadataEntry(entry) }
if (failures > 0) {
"Failed to sync $failures file(s)"
} else {
null
}
}
}
private suspend fun syncMetadataEntry(entry: FileMetadataEntity): Boolean {
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return false
val collection =
withContext(Dispatchers.IO) {
readCollectionJson(entry.fileUri)
} ?: return false
return withContext(Dispatchers.IO) {
try {
app.metadataSyncClient.uploadCollection(
accessToken = token,
trackId = entry.trackId,
type = entry.type,
version = entry.version,
collection = collection,
)
fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
true
} catch (e: IOException) {
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
false
}
}
}
private fun readCollectionJson(fileUri: String): JSONObject? {
val uri = Uri.parse(fileUri)
try {
context.contentResolver.openInputStream(uri)?.use { stream ->
return JSONObject(stream.bufferedReader().readText())
}
} catch (e: Exception) {
Log.w(METADATA_TAG, "readCollectionJson contentResolver failed uri=$fileUri", e)
}
return try {
val path = uri.path ?: return null
JSONObject(File(path).readText())
} catch (e: Exception) {
Log.w(METADATA_TAG, "readCollectionJson file path failed uri=$fileUri", e)
null
}
}
suspend fun syncJukeboxIfToken(): String? { suspend fun syncJukeboxIfToken(): String? {
val token = spotifyAccessToken.value val token = spotifyAccessToken.value
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
@@ -282,13 +562,34 @@ class LockstepViewModel(
app.playlistRepository.syncPlaylistDetail(playlistId) app.playlistRepository.syncPlaylistDetail(playlistId)
null null
} catch (e: LockstepApiException) { } catch (e: LockstepApiException) {
e.message ?: "Load failed" syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} catch (e: IOException) { } catch (e: IOException) {
e.message ?: "Load failed" syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} }
} }
} }
/**
* Playlist detail fetch is best-effort when opening from the library. Unauthorized responses mean
* the token is stale; cached jukebox rows (if any) are still valid for pairing and playback.
*/
private fun syncPlaylistDetailErrorForLibraryOpen(playlistId: String, e: Exception): String? {
if (isPlaylistDetailHttp401(e)) {
Log.w(TAG, "syncPlaylistDetail unauthorized for $playlistId, opening from jukebox cache", e)
return null
}
if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) {
Log.w(TAG, "syncPlaylistDetail failed for $playlistId, using cached tracks", e)
return null
}
return e.message ?: "Load failed"
}
private fun isPlaylistDetailHttp401(e: Exception): Boolean {
val msg = e.message ?: return false
return msg.contains("HTTP 401", ignoreCase = true)
}
fun completeOnboarding() { fun completeOnboarding() {
viewModelScope.launch { viewModelScope.launch {
prefs.setOnboardingComplete(true) prefs.setOnboardingComplete(true)

View File

@@ -0,0 +1,100 @@
package at.lockstep.player.data
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
sealed class MetadataFetchResult {
data class Found(val collection: JSONObject) : MetadataFetchResult()
data object NotFound : MetadataFetchResult()
}
class MetadataSyncClient(
private val httpClient: OkHttpClient,
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)
fun uploadCollection(
accessToken: String,
trackId: String,
type: String,
version: Int,
collection: JSONObject,
) {
val payload =
JSONObject()
.apply {
put("trackId", trackId)
put("type", type)
put("version", version)
put("collection", collection)
}.toString()
val request =
Request.Builder()
.url("$baseUrl/metadata")
.addHeader("Authorization", "Bearer $accessToken")
.post(payload.toRequestBody(JSON_MEDIA))
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return
}
val bodyText = response.body?.string().orEmpty()
val message =
runCatching { JSONObject(bodyText).optString("error") }
.getOrNull()
?.takeIf { it.isNotBlank() }
?: response.message
throw IOException(message)
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}
}

View File

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

View File

@@ -6,14 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [TrackPairingEntity::class], entities = [
version = 2, TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 6,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : abstract class AppDatabase :
RoomDatabase() { RoomDatabase() {
abstract fun pairingDao(): PairingDao abstract fun pairingDao(): PairingDao
abstract fun fileMetadataDao(): FileMetadataDao
companion object { companion object {
private const val DB_NAME = "lockstep.db" private const val DB_NAME = "lockstep.db"

View File

@@ -0,0 +1,28 @@
package at.lockstep.player.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface FileMetadataDao {
@Insert
suspend fun insert(row: FileMetadataEntity): Long
@Query("SELECT * FROM file_metadata WHERE synced = 0 AND type != 'beats' ORDER BY id ASC")
suspend fun listUnsynced(): List<FileMetadataEntity>
@Query("UPDATE file_metadata SET synced = 1, lastSyncedAt = :syncedAt WHERE id = :id")
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)
}

View File

@@ -0,0 +1,30 @@
package at.lockstep.player.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "file_metadata")
data class FileMetadataEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** Content or file URI of the persisted JSON. */
val fileUri: String,
/** Spotify track id for the collected run. */
val trackId: String,
/** e.g. [TYPE_COLLECTION], [TYPE_ANNOTATION], or [TYPE_BEATS]. */
val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */
val version: Int,
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 {
const val TYPE_COLLECTION = "collection"
const val TYPE_ANNOTATION = "annotation"
/** Canonical beat files under Documents/Lockstep/Beats/; tracked locally, not synced to server. */
const val TYPE_BEATS = "beats"
}
}

View File

@@ -21,7 +21,7 @@ public final class LibPasada {
if (loaded) { if (loaded) {
return; return;
} }
System.loadLibrary("pasada"); System.loadLibrary("lockstep-native");
loaded = true; loaded = true;
} }
@@ -72,4 +72,7 @@ public final class LibPasada {
/** Register listener for async events raised from the audio/native thread. */ /** Register listener for async events raised from the audio/native thread. */
public static native void setPlaybackListener(PasadaPlaybackListener listener); public static native void setPlaybackListener(PasadaPlaybackListener listener);
/** native version string */
public static native String getVersion();
} }

View File

@@ -9,6 +9,8 @@ public interface PasadaPlaybackListener {
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ /** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished(); void onTrackFinished();
void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ /** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message); void onError(int errorCode, String message);
} }

View File

@@ -17,6 +17,7 @@ import at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity import at.lockstep.player.MainActivity
import at.lockstep.player.R import at.lockstep.player.R
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine import at.lockstep.player.playback.engine.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -62,6 +63,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 +111,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,
), ),
@@ -202,7 +205,8 @@ class PlaybackService : Service() {
engine?.let { engine?.let {
return it return it
} }
return ExoPlayerMusicPlayerEngine(this) //return ExoPlayerMusicPlayerEngine(this)
return PasadaMusicPlayerEngine(this)
.also { .also {
it.setListener(engineListener) it.setListener(engineListener)
it.initSession() it.initSession()
@@ -264,6 +268,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 +304,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 +372,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 +532,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 +573,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 +587,7 @@ class PlaybackService : Service() {
isPlaying = false, isPlaying = false,
currentTrackId = null, currentTrackId = null,
currentQueueIndex = 0, currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0, queueSize = 0,
) )
} }
@@ -567,6 +600,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 {

View File

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

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log
import at.lockstep.player.pasada.LibPasada import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException import java.io.IOException
@@ -35,12 +36,18 @@ class PasadaMusicPlayerEngine(
private val nativeListener = private val nativeListener =
object : PasadaPlaybackListener { object : PasadaPlaybackListener {
override fun onTrackFinished() { override fun onTrackFinished() {
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
pendingStart = false pendingStart = false
mainHandler.post { mainHandler.post {
listener?.onPlaybackEnded() listener?.onPlaybackEnded()
} }
} }
override fun onTrackClosed(fd: Int) {
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
// we handle parcel close() separately
}
override fun onError( override fun onError(
errorCode: Int, errorCode: Int,
message: String, message: String,
@@ -51,13 +58,18 @@ class PasadaMusicPlayerEngine(
} }
} }
val TAG = "PasadaMusicPlayerEngine"
override fun initSession() { override fun initSession() {
if (sessionInitialized) { if (sessionInitialized) {
return return
} }
Log.i(TAG, "LibPasada.loadNative() ...")
LibPasada.loadNative() LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener) LibPasada.setPlaybackListener(nativeListener)
Log.i(TAG, "LibPasada.init() ...")
LibPasada.init() LibPasada.init()
Log.i(TAG, "LibPasada.init() done.")
sessionInitialized = true sessionInitialized = true
} }
@@ -131,7 +143,9 @@ class PasadaMusicPlayerEngine(
private fun startPendingTrack() { private fun startPendingTrack() {
val fd = trackFd ?: return val fd = trackFd ?: return
Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...")
LibPasada.play(fd, trackOffset, trackLength) LibPasada.play(fd, trackOffset, trackLength)
Log.i(TAG, "LibPasada.play() done.")
pendingStart = false pendingStart = false
} }

View File

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

View File

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

View File

@@ -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,
) )
} }

View File

@@ -49,9 +49,14 @@ fun LibraryScreen(
LaunchedEffect(token) { LaunchedEffect(token) {
if (token.isNullOrBlank()) return@LaunchedEffect if (token.isNullOrBlank()) return@LaunchedEffect
val err = viewModel.syncJukeboxIfToken() val skipMetadataSync = viewModel.consumeSuppressNextLibraryMetadataSync()
if (err != null) { val errors =
Toast.makeText(context, err, Toast.LENGTH_LONG).show() listOfNotNull(
viewModel.syncJukeboxIfToken(),
if (skipMetadataSync) null else viewModel.syncPendingMetadata(),
)
if (errors.isNotEmpty()) {
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
} }
} }

View File

@@ -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),
) { ) {

View File

@@ -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(
@@ -129,3 +136,4 @@ fun SettingsScreen(
} }
} }
} }
}

View File

@@ -5,20 +5,34 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.util.Locale import java.util.Locale
object BeatAnnotationStorage { object BeatAnnotationStorage {
private const val DIR_NAME = "beat_annotations"
fun annotationsDir(context: Context): File =
File(context.filesDir, DIR_NAME).apply { mkdirs() }
/** /**
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json". * [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
* Documents/Lockstep/{sessionFolder}/.
*/ */
fun writeAnnotationsFile( fun writeAnnotationsFile(
context: Context,
sessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
contentId: String,
title: String,
artist: String,
beatTimesMs: List<Long>,
): Uri? {
val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based)
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
}
/**
* 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/.
* Overwrites an existing file for the same track index.
*/
fun writeBeatsFile(
context: Context, context: Context,
playlistDisplayName: String, playlistDisplayName: String,
trackQueueIndex0Based: Int, trackQueueIndex0Based: Int,
@@ -26,26 +40,50 @@ object BeatAnnotationStorage {
title: String, title: String,
artist: String, artist: String,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
): File { ): Uri? {
val safeName = val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
playlistDisplayName val fileName = "$suffix.json"
.replace(Regex("[\\\\/:*?\"<>|]"), "_") val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
.trim() val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
.ifBlank { "playlist" } return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
.take(120) }
val suffix =
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) /** Writes server-fetched beat JSON under Documents/Lockstep/Beats/{playlist_name}/. */
val file = File(annotationsDir(context), "${safeName}_$suffix.json") 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 json = 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))
} }
file.writeText(json.toString(2)) }
return file
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. */

View File

@@ -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"
@@ -58,11 +66,34 @@ object RunDataStorage {
put("versionCode", BuildConfig.VERSION_CODE) put("versionCode", BuildConfig.VERSION_CODE)
}.toString() }.toString()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
} }
/** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */
fun writePublicJsonFile(
context: Context,
sessionFolder: String,
fileName: String,
jsonString: String,
): Uri? =
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? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
} else {
writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
} }
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray { private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
@@ -126,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 ->
@@ -159,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
} }

View File

@@ -0,0 +1,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.rex.logger;
import android.util.Log;
//import proguard.annotation.KeepName;
//@KeepName
/**
* Gather native log into logback, for easy control save log into SDCard
*/
public class NdkLogger {
private static final String TAG = "NDK";
//@KeepName
public static void logWrite(int level, String message) {
//sLogger.trace("level:{} message:{}", level, message);
switch (level) {
case Log.VERBOSE:
Log.v(TAG, message);
break;
case Log.DEBUG:
Log.d(TAG, message);
break;
case Log.INFO:
Log.i(TAG, message);
break;
case Log.WARN:
Log.w(TAG, message);
break;
case Log.ERROR:
Log.e(TAG, message);
break;
}
}
static {
try {
System.loadLibrary("ndk-logger");
} catch (UnsatisfiedLinkError ex) {
Log.e(TAG, "Failed to load library.", ex);
}
}
public static String getABI() { return native_getABI(); }
private static native String native_getABI();
}

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

View 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()