26 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
7d9654aa7e icon: app icon 2026-05-30 19:11:00 +02:00
d663a1c9de fixup: add missing file RunAccelSample 2026-05-24 10:57:09 +02:00
183efd343e feat: refactor out MusicPlayerEngine 2026-05-24 10:56:52 +02:00
eb53d1e0ad fix: thread-safe player position logging 2026-05-24 10:41:51 +02:00
b5bc01fac2 feat: also collect track position in ms, allows pause handling 2026-05-24 07:29:26 +02:00
698605d7a9 feat: add title and artist to collected json 2026-05-24 07:24:16 +02:00
c11ad041d7 feat: collect gyro and gps 2026-05-24 07:17:51 +02:00
4315944733 feat: scan media store for pairing 2026-05-24 06:47:10 +02:00
eb61437f34 feat: update folder selection to use SafInitialUris for improved document access 2026-05-24 06:37:46 +02:00
848f5919c8 feat: beat annotator 2026-05-15 09:03:20 +02:00
59 changed files with 3093 additions and 243 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,7 @@
# Bugs
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
- "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next."
@@ -21,6 +23,8 @@
## 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
- 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**.
## 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
- **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/).
**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/…`.
Submodule: [`jukebox/`](jukebox/).

View File

@@ -84,6 +84,7 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.media:media:1.7.0")
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

@@ -2,6 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -9,9 +18,9 @@
<application
android:name=".LockstepApplication"
android:allowBackup="true"
android:icon="@drawable/ic_launcher_layer"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_layer"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LockstepPlayer">
<activity

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.os.Build
import at.lockstep.jukebox.Jukebox
import at.lockstep.jukebox.PlaylistRepository
import at.lockstep.player.data.MetadataSyncClient
import at.lockstep.player.data.db.AppDatabase
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicReference
class LockstepApplication : Application() {
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
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 {
Jukebox.playlistRepository(
this,

View File

@@ -7,12 +7,19 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import at.lockstep.jukebox.api.LockstepApiException
import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.data.MetadataFetchResult
import at.lockstep.player.data.UserPreferencesRepository
import at.lockstep.player.data.db.FileMetadataEntity
import at.lockstep.player.data.db.TrackPairingEntity
import at.lockstep.player.util.AudioUriValidator
import at.lockstep.player.playback.TrackBoundaryEvent
import at.lockstep.player.util.BeatAnnotationStorage
import at.lockstep.player.util.FolderMp3Scanner
import at.lockstep.player.util.MediaStoreMp3Scanner
import at.lockstep.player.util.Mp3EmbeddedMetadata
import at.lockstep.player.util.Mp3FolderCandidate
import at.lockstep.player.util.RunDataStorage
import at.lockstep.player.util.RunTrackDataSnapshot
import at.lockstep.player.util.TrackFileMatching
import at.lockstep.player.util.mp3DisplayNameFromUri
import kotlinx.coroutines.Dispatchers
@@ -23,6 +30,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
@@ -33,6 +42,7 @@ class LockstepViewModel(
companion object {
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. */
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
@@ -41,6 +51,7 @@ class LockstepViewModel(
}
private val prefs = UserPreferencesRepository(application)
private val pairingDao get() = app.database.pairingDao()
private val fileMetadataDao get() = app.database.fileMetadataDao()
val onboardingComplete: StateFlow<Boolean> =
prefs.onboardingComplete.stateIn(
@@ -56,6 +67,32 @@ class LockstepViewModel(
null,
)
val annotationMode: StateFlow<Boolean> =
prefs.annotationMode.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
val collectRunData: StateFlow<Boolean> =
prefs.collectRunData.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
fun setAnnotationMode(enabled: Boolean) {
viewModelScope.launch {
prefs.setAnnotationMode(enabled)
}
}
fun setCollectRunData(enabled: Boolean) {
viewModelScope.launch {
prefs.setCollectRunData(enabled)
}
}
private val context get() = getApplication<Application>()
/**
@@ -120,6 +157,379 @@ class LockstepViewModel(
suspend fun hasPairedTracks(playlistId: String): Boolean = pairingDao.countPaired(playlistId) > 0
suspend fun getPlaylistDisplayName(playlistId: String): String =
withContext(Dispatchers.IO) {
app.playlistRepository.getPlaylists()
.find { it.id == playlistId }
?.name
?.trim()
.orEmpty()
.ifBlank { "playlist" }
}
/**
* Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
* and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
* Skips when [beatTimesMs] is empty.
*/
fun persistBeatAnnotation(
playlistId: String,
playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent,
beatTimesMs: List<Long>,
) {
if (beatTimesMs.isEmpty()) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
val contentId = docId.ifBlank { event.trackId }
val appContext = getApplication<Application>()
val annotationUri =
BeatAnnotationStorage.writeAnnotationsFile(
context = appContext,
sessionFolder = sessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.playlistPosition,
contentId = contentId,
title = event.title,
artist = event.artist,
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)
}
}
/**
* 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.
*/
fun persistRunData(
playlistId: String,
playlistDisplayName: String,
runSessionFolder: String,
event: TrackBoundaryEvent,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.playlistPosition,
trackId = event.trackId,
metaContentUri = meta,
title = event.title,
artist = event.artist,
snapshot = snapshot,
)
}
}
/** Flush in-progress run data when leaving Now Playing before a track boundary fires. */
fun persistRunDataForCurrentTrack(
playlistId: String,
playlistDisplayName: String,
runSessionFolder: String,
trackId: String,
title: String,
artist: String,
playlistPosition: Int,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = playlistPosition,
trackId = trackId,
metaContentUri = meta,
title = title,
artist = artist,
snapshot = snapshot,
)
}
}
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? {
val token = spotifyAccessToken.value
if (token.isNullOrBlank()) {
@@ -152,13 +562,34 @@ class LockstepViewModel(
app.playlistRepository.syncPlaylistDetail(playlistId)
null
} catch (e: LockstepApiException) {
e.message ?: "Load failed"
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} 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() {
viewModelScope.launch {
prefs.setOnboardingComplete(true)
@@ -239,25 +670,7 @@ class LockstepViewModel(
}
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
val withId = tracks.count { !it.trackId.isNullOrBlank() }
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
Log.d(
TAG,
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
"withTitle=$withTitle withArtist=$withArtist",
)
tracks.take(12).forEach { row ->
Log.d(
TAG,
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
"artist='${row.artistName}' durationMs=${row.durationMs}",
)
}
if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
}
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
@@ -274,75 +687,117 @@ class LockstepViewModel(
}.toMutableList()
Pair(mp3s.size, p)
}
if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
onFinished(
FolderPairingResult(
paired = 0,
failed = 0,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0,
),
)
return@launch
}
Log.d(
TAG,
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
"files have title or artist from tags",
)
var paired = 0
var failed = 0
var skippedNoSpotifyTrackId = 0
for (track in tracks.sortedBy { it.position }) {
val tid = track.trackId
if (tid.isNullOrBlank()) {
skippedNoSpotifyTrackId++
failed++
Log.d(
TAG,
"skip row pos=${track.position}: no Spotify track id " +
"(title='${track.trackName}' artist='${track.artistName}')",
)
continue
}
val title = track.trackName.orEmpty()
val artist = track.artistName.orEmpty()
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
if (pick == null) {
failed++
continue
}
val err =
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
if (err != null) {
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
upsertPairing(playlistId, tid, null, err)
failed++
} else {
upsertPairing(playlistId, tid, pick.toString(), null)
paired++
pool.removeAll { it.uri == pick }
}
}
Log.d(
TAG,
"pairPlaylistFromFolder done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
)
onFinished(
FolderPairingResult(
paired = paired,
failed = failed,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
),
)
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
/** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */
fun pairPlaylistFromDeviceAudio(
playlistId: String,
onFinished: (FolderPairingResult) -> Unit,
) {
viewModelScope.launch {
Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId")
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val candidates = MediaStoreMp3Scanner.listMp3Candidates(context)
Pair(candidates.size, candidates.toMutableList())
}
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
private fun logJukeboxTracks(tracks: List<TrackRow>) {
val withId = tracks.count { !it.trackId.isNullOrBlank() }
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
Log.d(
TAG,
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
"withTitle=$withTitle withArtist=$withArtist",
)
tracks.take(12).forEach { row ->
Log.d(
TAG,
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
"artist='${row.artistName}' durationMs=${row.durationMs}",
)
}
if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
}
}
private suspend fun matchPlaylistTracks(
playlistId: String,
tracks: List<TrackRow>,
pool: MutableList<Mp3FolderCandidate>,
mp3Total: Int,
): FolderPairingResult {
if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
return FolderPairingResult(
paired = 0,
failed = 0,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0,
)
}
Log.d(
TAG,
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
"files have title or artist from tags",
)
var paired = 0
var failed = 0
var skippedNoSpotifyTrackId = 0
for (track in tracks.sortedBy { it.position }) {
val tid = track.trackId
if (tid.isNullOrBlank()) {
skippedNoSpotifyTrackId++
failed++
Log.d(
TAG,
"skip row pos=${track.position}: no Spotify track id " +
"(title='${track.trackName}' artist='${track.artistName}')",
)
continue
}
val title = track.trackName.orEmpty()
val artist = track.artistName.orEmpty()
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
if (pick == null) {
failed++
continue
}
val err =
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
if (err != null) {
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
upsertPairing(playlistId, tid, null, err)
failed++
} else {
upsertPairing(playlistId, tid, pick.toString(), null)
paired++
pool.removeAll { it.uri == pick }
}
}
Log.d(
TAG,
"playlist pairing done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
)
return FolderPairingResult(
paired = paired,
failed = failed,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
)
}
}

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

@@ -27,6 +27,18 @@ class UserPreferencesRepository(
prefs[KEY_SPOTIFY_ACCESS_TOKEN]
}
/** When true, opening a paired playlist navigates to beat annotation instead of Now Playing. */
val annotationMode: Flow<Boolean> =
dataStore.data.map { prefs ->
prefs[KEY_ANNOTATION_MODE] == true
}
/** When true, Now Playing records sensor samples per track into JSON under Documents/Lockstep/{sessionFolder}/. */
val collectRunData: Flow<Boolean> =
dataStore.data.map { prefs ->
prefs[KEY_COLLECT_RUN_DATA] == true
}
suspend fun setOnboardingComplete(done: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_ONBOARDING_COMPLETE] = done
@@ -43,8 +55,22 @@ class UserPreferencesRepository(
}
}
suspend fun setAnnotationMode(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_ANNOTATION_MODE] = enabled
}
}
suspend fun setCollectRunData(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_COLLECT_RUN_DATA] = enabled
}
}
companion object {
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
}
}

View File

@@ -6,14 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [TrackPairingEntity::class],
version = 2,
entities = [
TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 6,
exportSchema = false,
)
abstract class AppDatabase :
RoomDatabase() {
abstract fun pairingDao(): PairingDao
abstract fun fileMetadataDao(): FileMetadataDao
companion object {
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

@@ -24,6 +24,14 @@ interface PairingDao {
@Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
suspend fun listForPlaylist(playlistId: String): List<TrackPairingEntity>
@Query(
"SELECT * FROM track_pairings WHERE playlistId = :playlistId AND trackId = :trackId LIMIT 1",
)
suspend fun findForTrack(
playlistId: String,
trackId: String,
): TrackPairingEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: TrackPairingEntity)
}

View File

@@ -0,0 +1,78 @@
package at.lockstep.player.pasada;
/**
* JNI entry point for libpasada. Used by {@link at.lockstep.player.playback.engine.PasadaMusicPlayerEngine};
* {@link at.lockstep.player.playback.PlaybackService} still defaults to
* {@link at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine} until the native library is ready.
*
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*
* <p>Call {@link #loadNative()} once before any other method once the {@code pasada} shared
* library is added via CMake (step 3).
*/
public final class LibPasada {
private static boolean loaded;
private LibPasada() {}
/** Loads {@code libpasada.so}. Safe to call multiple times. */
public static synchronized void loadNative() {
if (loaded) {
return;
}
System.loadLibrary("lockstep-native");
loaded = true;
}
/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */
public static native void init();
/** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */
public static native void feedAccel(float x, float y, float z, long timestampNanos);
/**
* Open MP3 from an already-open file descriptor and begin adaptive playback.
*
* @param fd open read FD (Java retains ownership; do not close until track changes)
* @param offset start offset within the FD (0 for whole file)
* @param length byte length from offset ({@code -1} if unknown / to EOF)
*/
public static native void play(int fd, long offset, long length);
/** PLAYING → PAUSED (silent output, graph kept alive). */
public static native void pause();
/** PAUSED → PLAYING (same track, same decode position, same FD). */
public static native void resume();
/** Tear down Oboe for this run segment → STOPPED. */
public static native void stop();
/**
* Milliseconds from the start of the current track — same timebase as ExoPlayer
* {@code getCurrentPosition()}. Safe to poll from background threads.
*/
public static native long getCurrentPositionMs();
/** Track duration in ms, or {@code 0} if not yet known. */
public static native long getDurationMs();
/** Whether adapted audio is actively being output (not paused, not finished). */
public static native boolean isPlaying();
/** Current native state; see {@link PasadaState}. */
public static native int getState();
/** Seek within the current track. */
public static native void seekTo(long positionMs);
/** Runtime metrics / last error string for logging and debug UI. */
public static native String getDiagnostics();
/** Register listener for async events raised from the audio/native thread. */
public static native void setPlaybackListener(PasadaPlaybackListener listener);
/** native version string */
public static native String getVersion();
}

View File

@@ -0,0 +1,16 @@
package at.lockstep.player.pasada;
/**
* Callbacks invoked from native (Oboe or internal worker thread).
* Implementations must post to the main thread if they touch UI or service state.
*/
public interface PasadaPlaybackListener {
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished();
void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message);
}

View File

@@ -0,0 +1,26 @@
package at.lockstep.player.pasada;
/** Mirrors the libpasada state machine documented in DESIGN.md. */
public enum PasadaState {
LOADED(0),
INITIALIZED(1),
PLAYING(2),
PAUSED(3),
FINISHED(4),
STOPPED(5);
public final int code;
PasadaState(int code) {
this.code = code;
}
public static PasadaState fromCode(int code) {
for (PasadaState state : values()) {
if (state.code == code) {
return state;
}
}
throw new IllegalArgumentException("Unknown PasadaState code: " + code);
}
}

View File

@@ -16,15 +16,30 @@ import androidx.media.app.NotificationCompat.MediaStyle
import at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* current media item).
*/
class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var mediaSession: MediaSessionCompat
@@ -32,11 +47,77 @@ class PlaybackService : Service() {
private val binder = LocalBinder()
private var engine: MusicPlayerEngine? = null
private var positionPollJob: Job? = null
private var positionCachePollJob: Job? = null
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
private val _trackBoundary =
MutableSharedFlow<TrackBoundaryEvent>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val trackBoundaryEvents: SharedFlow<TrackBoundaryEvent> = _trackBoundary.asSharedFlow()
private var queue: List<TrackQueueItem> = emptyList()
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. */
@Volatile
private var cachedPlaybackPositionMs: Long = 0L
private val engineListener =
object : MusicPlayerEngine.Listener {
override fun onPlaybackEnded() {
if (queue.isEmpty()) {
return
}
if (index < queue.lastIndex) {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
)
index++
publishCurrentTrack()
} else {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.LAST_TRACK_FINISHED,
)
setPlaying(false)
}
}
override fun onError(
errorCode: Int,
message: String,
) {
setPlaying(false)
}
}
private fun emitTrackBoundaryForQueueIndex(
i: Int,
reason: TrackBoundaryReason,
) {
val item = queue.getOrNull(i) ?: return
scope.launch {
_trackBoundary.emit(
TrackBoundaryEvent(
trackId = item.id,
title = item.title,
artist = item.artist,
queueIndex = i,
playlistPosition = item.playlistPosition,
queueSize = queue.size,
reason = reason,
),
)
}
}
inner class LocalBinder : Binder() {
fun getService(): PlaybackService = this@PlaybackService
@@ -65,6 +146,11 @@ class PlaybackService : Service() {
override fun onSkipToPrevious() {
skipDelta(-1)
}
override fun onSeekTo(pos: Long) {
engine?.seekTo(pos)
updateProgressFromEngine()
}
},
)
mediaSession.isActive = true
@@ -115,6 +201,51 @@ class PlaybackService : Service() {
}
}
private fun ensureEngine(): MusicPlayerEngine {
engine?.let {
return it
}
//return ExoPlayerMusicPlayerEngine(this)
return PasadaMusicPlayerEngine(this)
.also {
it.setListener(engineListener)
it.initSession()
engine = it
startPositionPolling()
}
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
scope.launch {
while (isActive) {
delay(UPDATE_INTERVAL_MS)
if (engine != null && queue.isNotEmpty()) {
updateProgressFromEngine()
}
}
}
positionCachePollJob?.cancel()
positionCachePollJob =
scope.launch {
while (isActive) {
delay(POSITION_CACHE_INTERVAL_MS)
refreshCachedPlaybackPositionMs()
}
}
}
private fun releaseEngine() {
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
engine?.setListener(null)
engine?.releaseSession()
engine = null
}
private fun startPlaylist(pid: String) {
scope.launch(Dispatchers.IO) {
val rows = app.playlistRepository.getTracks(pid)
@@ -128,45 +259,71 @@ class PlaybackService : Service() {
if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
return@mapNotNull null
}
val hint =
row.durationMs?.takeIf { it > 0 }
?: DEFAULT_DURATION_HINT_MS
TrackQueueItem(
id = tid,
title = row.trackName ?: "",
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
durationMsHint = hint,
playlistPosition = row.position,
)
}
index = 0
if (queue.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
withContext(Dispatchers.Main) {
releaseEngine()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return@launch
}
withContext(Dispatchers.Main) {
ensureEngine()
setPlaying(true)
publishCurrentTrack()
refreshForegroundNotification()
}
}
}
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
val durationSec = 180
applyCurrentMediaItem(item)
val durationSec =
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value =
PlaybackUiState(
title = item.title,
artist = item.artist,
progress = _uiState.value.progress,
progress = 0f,
durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentQueueIndex = index,
currentPlaylistPosition = item.playlistPosition,
queueSize = queue.size,
)
updateSessionMetadata(
item,
durationSec,
)
updatePlaybackState()
updateProgressFromEngine()
updateSessionMetadata(item, durationSec)
updatePlaybackStateFromEngine()
refreshForegroundNotification()
}
private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return
val e = ensureEngine()
e.prepareTrack(uri)
if (_uiState.value.isPlaying) {
e.play()
} else {
e.pause()
}
}
private fun updateSessionMetadata(
item: TrackQueueItem,
durationSec: Int,
@@ -180,14 +337,59 @@ class PlaybackService : Service() {
)
}
private fun updatePlaybackState() {
private fun currentDurationMs(): Long {
val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
if (fromEngine != null) {
return fromEngine
}
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint
}
private fun refreshCachedPlaybackPositionMs() {
val e = engine ?: return
if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
}
private fun updateProgressFromEngine() {
val e = engine ?: return
refreshCachedPlaybackPositionMs()
if (queue.isEmpty()) {
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = cachedPlaybackPositionMs
val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1)
_uiState.value =
_uiState.value.copy(
progress = progress,
durationSeconds = durationSec,
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
currentQueueIndex = index,
currentPlaylistPosition =
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
queueSize = queue.size,
)
updatePlaybackStateFromEngine()
}
private fun updatePlaybackStateFromEngine() {
val e = engine
val positionMs = e?.getCurrentPositionMs() ?: 0L
val actions =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
val state =
if (_uiState.value.isPlaying) {
if (_uiState.value.isPlaying && e?.isPlaying() == true) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
@@ -195,14 +397,19 @@ class PlaybackService : Service() {
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.setState(state, positionMs, 1f)
.build(),
)
}
private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing)
updatePlaybackState()
if (playing) {
engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
refreshForegroundNotification()
}
@@ -212,8 +419,16 @@ class PlaybackService : Service() {
}
private fun skipDelta(delta: Int) {
if (queue.isEmpty()) return
index = (index + delta).coerceIn(0, queue.lastIndex)
if (queue.isEmpty()) {
return
}
val oldIndex = index
val newIndex = (oldIndex + delta).coerceIn(0, queue.lastIndex)
if (newIndex == oldIndex) {
return
}
emitTrackBoundaryForQueueIndex(oldIndex, TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK)
index = newIndex
publishCurrentTrack()
}
@@ -231,33 +446,19 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return
_uiState.value =
_uiState.value.copy(
progress = fraction.coerceIn(0f, 1f),
)
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO,
)
.setState(
if (_uiState.value.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
(_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(),
1f,
)
.build(),
)
refreshForegroundNotification()
val e = engine ?: return
val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
e.seekTo(positionMs)
updateProgressFromEngine()
}
/**
* Milliseconds from the start of the current track — same timebase as the playback engine.
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
*/
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
private fun refreshForegroundNotification() {
if (queue.isEmpty()) return
val notification = buildNotification()
@@ -331,20 +532,50 @@ class PlaybackService : Service() {
.build()
}
override fun onTaskRemoved(rootIntent: Intent?) {
stopPlaybackAndTeardown()
stopSelf()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
mediaSession.run {
isActive = false
release()
}
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 = null
positionCachePollJob?.cancel()
positionCachePollJob = null
releaseEngine()
queue = emptyList()
index = 0
cachedPlaybackPositionMs = 0L
_uiState.value = PlaybackUiState.initial()
if (::mediaSession.isInitialized) {
mediaSession.run {
isActive = false
release()
}
}
stopForeground(STOP_FOREGROUND_REMOVE)
}
data class PlaybackUiState(
val title: String,
val artist: String,
val progress: Float,
val durationSeconds: Int,
val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
/** 0-based position in the full Spotify playlist for the current track. */
val currentPlaylistPosition: Int,
val queueSize: Int,
) {
companion object {
fun initial() =
@@ -354,6 +585,10 @@ class PlaybackService : Service() {
progress = 0f,
durationSeconds = 180,
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0,
)
}
}
@@ -363,9 +598,17 @@ class PlaybackService : Service() {
val title: String,
val artist: String,
val localUri: Uri?,
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
/** 0-based position in the full Spotify playlist. */
val playlistPosition: Int,
)
companion object {
private const val UPDATE_INTERVAL_MS = 250L
private const val POSITION_CACHE_INTERVAL_MS = 20L
private const val DEFAULT_DURATION_HINT_MS = 180_000
const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"

View File

@@ -0,0 +1,25 @@
package at.lockstep.player.playback
/**
* Fired when annotation should flush beats for the track being left:
* user skipped, auto-advance after a track ended, or the last track finished.
*/
data class TrackBoundaryEvent(
val trackId: String,
val title: String,
val artist: String,
/** 0-based index in the paired-only play queue when this track was current. */
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 reason: TrackBoundaryReason,
)
enum class TrackBoundaryReason {
/** Left this track for another (skip, previous, or middle track ended). */
ADVANCED_TO_OTHER_TRACK,
/** Playback of the last track in the playlist completed. */
LAST_TRACK_FINISHED,
}

View File

@@ -0,0 +1,72 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
/** [MusicPlayerEngine] backed by Media3 [ExoPlayer] until libpasada is wired in. */
class ExoPlayerMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private var player: ExoPlayer? = null
private var listener: MusicPlayerEngine.Listener? = null
private val playerListener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
listener?.onPlaybackEnded()
}
}
}
override fun initSession() {
if (player != null) {
return
}
player =
ExoPlayer.Builder(appContext)
.build()
.also { it.addListener(playerListener) }
}
override fun prepareTrack(uri: Uri) {
val p = requirePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
}
override fun play() {
requirePlayer().playWhenReady = true
}
override fun pause() {
player?.playWhenReady = false
}
override fun seekTo(positionMs: Long) {
requirePlayer().seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long = player?.currentPosition ?: 0L
override fun getDurationMs(): Long = player?.duration?.takeIf { it > 0 } ?: 0L
override fun isPlaying(): Boolean = player?.isPlaying == true
override fun releaseSession() {
player?.removeListener(playerListener)
player?.release()
player = null
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun requirePlayer(): ExoPlayer =
player ?: throw IllegalStateException("Call initSession() before using the player")
}

View File

@@ -0,0 +1,44 @@
package at.lockstep.player.playback.engine
import android.net.Uri
/**
* Playback engine used by [at.lockstep.player.playback.PlaybackService].
* ExoPlayer today; libpasada via JNI later.
*/
interface MusicPlayerEngine {
/** Arm the engine for a new playlist/run session. */
fun initSession()
/**
* Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is
* called (or immediately if [playWhenReady] is set via [play]).
*/
fun prepareTrack(uri: Uri)
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun getCurrentPositionMs(): Long
fun getDurationMs(): Long
fun isPlaying(): Boolean
/** Release resources for this session. */
fun releaseSession()
fun setListener(listener: Listener?)
interface Listener {
fun onPlaybackEnded()
fun onError(
errorCode: Int,
message: String,
)
}
}

View File

@@ -0,0 +1,188 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException
/**
* [MusicPlayerEngine] backed by libpasada JNI. Opens local URIs into file descriptors for native
* decode; maps prepare/play/pause semantics onto [LibPasada.play] / [LibPasada.resume].
*/
class PasadaMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private var listener: MusicPlayerEngine.Listener? = null
private var openAsset: AssetFileDescriptor? = null
private var openParcel: ParcelFileDescriptor? = null
private var trackFd: Int? = null
private var trackOffset: Long = 0L
private var trackLength: Long = -1L
/** FD is open but [LibPasada.play] has not been called yet for this track. */
private var pendingStart = false
private var sessionInitialized = false
private val nativeListener =
object : PasadaPlaybackListener {
override fun onTrackFinished() {
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
pendingStart = false
mainHandler.post {
listener?.onPlaybackEnded()
}
}
override fun onTrackClosed(fd: Int) {
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
// we handle parcel close() separately
}
override fun onError(
errorCode: Int,
message: String,
) {
mainHandler.post {
listener?.onError(errorCode, message)
}
}
}
val TAG = "PasadaMusicPlayerEngine"
override fun initSession() {
if (sessionInitialized) {
return
}
Log.i(TAG, "LibPasada.loadNative() ...")
LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener)
Log.i(TAG, "LibPasada.init() ...")
LibPasada.init()
Log.i(TAG, "LibPasada.init() done.")
sessionInitialized = true
}
override fun prepareTrack(uri: Uri) {
closeOpenTrack()
try {
openTrack(uri)
pendingStart = true
} catch (e: IOException) {
closeOpenTrack()
listener?.onError(ERROR_OPEN_TRACK, e.message ?: "Failed to open $uri")
}
}
override fun play() {
if (pendingStart) {
startPendingTrack()
return
}
if (!sessionInitialized) {
return
}
if (!LibPasada.isPlaying()) {
LibPasada.resume()
}
}
override fun pause() {
if (pendingStart) {
return
}
LibPasada.pause()
}
override fun seekTo(positionMs: Long) {
if (pendingStart) {
return
}
LibPasada.seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getCurrentPositionMs()
}
override fun getDurationMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getDurationMs()
}
override fun isPlaying(): Boolean =
sessionInitialized && !pendingStart && LibPasada.isPlaying()
override fun releaseSession() {
closeOpenTrack()
if (sessionInitialized) {
LibPasada.setPlaybackListener(null)
LibPasada.stop()
sessionInitialized = false
}
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun startPendingTrack() {
val fd = trackFd ?: return
Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...")
LibPasada.play(fd, trackOffset, trackLength)
Log.i(TAG, "LibPasada.play() done.")
pendingStart = false
}
@Throws(IOException::class)
private fun openTrack(uri: Uri) {
val resolver = appContext.contentResolver
resolver.openAssetFileDescriptor(uri, "r")?.let { afd ->
val pfd =
afd.parcelFileDescriptor
?: throw IOException("No ParcelFileDescriptor for $uri")
openAsset = afd
trackFd = pfd.fd
trackOffset = afd.startOffset
trackLength = if (afd.length >= 0) afd.length else -1L
return
}
val pfd =
resolver.openFileDescriptor(uri, "r")
?: throw IOException("Cannot open $uri")
openParcel = pfd
trackFd = pfd.fd
trackOffset = 0L
trackLength = -1L
}
private fun closeOpenTrack() {
openAsset?.close()
openAsset = null
openParcel?.close()
openParcel = null
trackFd = null
trackOffset = 0L
trackLength = -1L
pendingStart = false
}
private companion object {
const val ERROR_OPEN_TRACK = 1
}
}

View File

@@ -0,0 +1,215 @@
package at.lockstep.player.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R
import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.util.RunDataStorage
/**
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
* timeline). JSON is written only when the track changes (skip/prev/next or natural advance) or when
* the last track in the playlist finishes — see [PlaybackService.trackBoundaryEvents].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnnotationRoute(
playlistId: String,
playback: PlaybackService?,
viewModel: LockstepViewModel,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val beatListHeight = LocalConfiguration.current.screenHeightDp.dp / 5
var ui by remember {
mutableStateOf(
NowPlayingUiState(
title = context.getString(R.string.now_playing_idle_title),
artist = context.getString(R.string.now_playing_idle_artist),
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
progress = 0f,
durationSeconds = 180,
isPlaying = false,
),
)
}
LaunchedEffect(playback) {
val service = playback ?: return@LaunchedEffect
service.uiState.collect { p ->
ui =
NowPlayingUiState(
title = p.title,
artist = p.artist,
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
progress = p.progress,
durationSeconds = p.durationSeconds,
isPlaying = p.isPlaying,
)
}
}
val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
var playlistDisplayName by remember { mutableStateOf("playlist") }
LaunchedEffect(playlistId) {
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
}
LaunchedEffect(playback, playlistId, annotationSessionFolder) {
val service = playback ?: return@LaunchedEffect
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList()
beatTimesMs.clear()
viewModel.persistBeatAnnotation(
playlistId,
name,
annotationSessionFolder,
event,
snapshot,
)
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = context.getString(R.string.annotation_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding),
) {
Text(
text = context.getString(R.string.annotation_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 420.dp)
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NowPlayingScreen(
state = ui,
onProgressChange = { fraction ->
playback?.requestSeek(fraction)
ui = ui.copy(progress = fraction)
},
onPrevious = { playback?.requestSkipPrevious() },
onTogglePlayPause = { playback?.requestTogglePause() },
onNext = { playback?.requestSkipNext() },
modifier = Modifier.fillMaxWidth(),
)
}
Text(
text = context.getString(R.string.annotation_beat_count, beatTimesMs.size),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
)
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(MaterialTheme.colorScheme.primaryContainer)
.pointerInput(playback) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
val t = playback?.getPlaybackPositionMs() ?: 0L
beatTimesMs.add(t)
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.annotation_tap_area_label),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.height(beatListHeight)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(beatTimesMs) { i, ms ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${i + 1}.",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = context.getString(R.string.annotation_time_ms, ms.toInt()),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.navigation.NavHostController
import androidx.navigation.NavType
@@ -32,6 +33,7 @@ fun LockstepAppNavHost(
navController: NavHostController,
viewModel: LockstepViewModel,
) {
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
var playback by remember { mutableStateOf<PlaybackService?>(null) }
DisposableEffect(Unit) {
@@ -70,7 +72,11 @@ fun LockstepAppNavHost(
navController.navigate(Routes.pairing(playlist.id))
} else {
startPlaylistPlayback(activity, playlist.id)
navController.navigate(Routes.nowPlaying(playlist.id))
if (annotationMode) {
navController.navigate(Routes.annotation(playlist.id))
} else {
navController.navigate(Routes.nowPlaying(playlist.id))
}
}
},
onOpenSettings = {
@@ -109,6 +115,25 @@ fun LockstepAppNavHost(
NowPlayingRoute(
playlistId = playlistId,
playback = playback,
viewModel = viewModel,
onBack = {
viewModel.suppressNextLibraryMetadataSync()
navController.popBackStack()
},
)
}
composable(
route = Routes.Annotation,
arguments =
listOf(
navArgument("playlistId") { type = NavType.StringType },
),
) { entry ->
val playlistId = entry.arguments?.getString("playlistId").orEmpty()
AnnotationRoute(
playlistId = playlistId,
playback = playback,
viewModel = viewModel,
onBack = { navController.popBackStack() },
)
}

View File

@@ -1,5 +1,10 @@
package at.lockstep.player.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -22,9 +27,11 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -34,8 +41,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.R
import at.lockstep.player.util.RunDataCollector
import at.lockstep.player.util.RunDataStorage
data class NowPlayingUiState(
val title: String,
@@ -153,10 +165,39 @@ fun NowPlayingScreen(
fun NowPlayingRoute(
playlistId: String,
playback: PlaybackService?,
viewModel: LockstepViewModel,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
BackHandler(onBack = onBack)
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
val collector = remember { RunDataCollector(context) }
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
val locationPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (collectRunData) {
collector.start(enableLocation = granted)
}
},
)
fun startRunDataCollection() {
val hasLocation =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!hasLocation) {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
collector.start(enableLocation = hasLocation)
}
var playlistDisplayName by remember { mutableStateOf("playlist") }
var currentTrackId by remember { mutableStateOf<String?>(null) }
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
var ui by remember {
mutableStateOf(
NowPlayingUiState(
@@ -170,9 +211,17 @@ fun NowPlayingRoute(
)
}
LaunchedEffect(playlistId) {
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
}
LaunchedEffect(playback) {
val service = playback ?: return@LaunchedEffect
service.uiState.collect { p ->
currentTrackId = p.currentTrackId
currentPlaylistPosition = p.currentPlaylistPosition
ui =
NowPlayingUiState(
title = p.title,
@@ -185,6 +234,66 @@ fun NowPlayingRoute(
}
}
LaunchedEffect(collectRunData, playback) {
if (!collectRunData) {
collector.setCollectingEnabled(false)
return@LaunchedEffect
}
val service = playback ?: run {
collector.setCollectingEnabled(false)
return@LaunchedEffect
}
collector.setPlaybackPositionMsProvider { service.getPlaybackPositionMs() }
collector.setCollectingEnabled(true)
var lastTrackId: String? = null
service.uiState.collect { state ->
val trackId = state.currentTrackId
if (trackId != null && trackId != lastTrackId) {
collector.markSongStart()
lastTrackId = trackId
}
}
}
LaunchedEffect(collectRunData, playback, playlistId) {
if (!collectRunData) return@LaunchedEffect
val service = playback ?: return@LaunchedEffect
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
service.trackBoundaryEvents.collect { event ->
val snapshot = collector.snapshotAndClear()
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
}
}
DisposableEffect(collectRunData) {
if (collectRunData) {
startRunDataCollection()
} else {
collector.stop()
collector.snapshotAndClear()
}
onDispose {
if (collectRunData) {
val snapshot = collector.snapshotAndClear()
val trackId = currentTrackId
if (!snapshot.isEmpty() && trackId != null) {
viewModel.persistRunDataForCurrentTrack(
playlistId = playlistId,
playlistDisplayName = playlistDisplayName,
runSessionFolder = runSessionFolder,
trackId = trackId,
title = ui.title,
artist = ui.artist,
playlistPosition = currentPlaylistPosition,
snapshot = snapshot,
)
}
}
collector.release()
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {

View File

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

View File

@@ -6,8 +6,11 @@ object Routes {
const val Settings = "settings"
const val Pairing = "pairing/{playlistId}"
const val NowPlaying = "nowPlaying/{playlistId}"
const val Annotation = "annotation/{playlistId}"
fun pairing(playlistId: String) = "pairing/$playlistId"
fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId"
fun annotation(playlistId: String) = "annotation/$playlistId"
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -50,6 +51,7 @@ fun OnboardingScreen(
modifier =
modifier
.fillMaxSize()
.statusBarsPadding()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {

View File

@@ -1,5 +1,6 @@
package at.lockstep.player.ui.pairing
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -33,10 +35,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.FolderPairingResult
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R
import at.lockstep.player.util.AudioReadPermission
import at.lockstep.player.util.SafInitialUris
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,54 +84,44 @@ fun PairingScreen(
onResult = { tree ->
if (tree != null) {
viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
when {
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
showPairingResultToast(context, r)
}
}
},
)
val runDeviceScan = {
viewModel.pairPlaylistFromDeviceAudio(playlistId) { r ->
showPairingResultToast(context, r)
}
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
runDeviceScan()
} else {
Toast
.makeText(
context,
context.getString(R.string.pairing_audio_permission_denied),
Toast.LENGTH_LONG,
).show()
}
},
)
fun scanDeviceAudio() {
val permission = AudioReadPermission.permissionName()
when {
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ->
runDeviceScan()
else -> audioPermissionLauncher.launch(permission)
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -149,7 +145,22 @@ fun PairingScreen(
) {
item {
Button(
onClick = { openTree.launch(null) },
onClick = { scanDeviceAudio() },
modifier = Modifier.fillMaxWidth(),
) {
Text(context.getString(R.string.pairing_scan_device))
}
}
item {
Text(
text = context.getString(R.string.pairing_scan_device_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
OutlinedButton(
onClick = { openTree.launch(SafInitialUris.internalDocuments(context)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(context.getString(R.string.pairing_choose_folder))
@@ -215,3 +226,52 @@ fun PairingScreen(
}
}
}
private fun showPairingResultToast(
context: android.content.Context,
r: FolderPairingResult,
) {
when {
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
}

View File

@@ -2,7 +2,9 @@ package at.lockstep.player.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -13,12 +15,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R
@@ -30,6 +36,8 @@ fun SettingsScreen(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -51,24 +59,80 @@ fun SettingsScreen(
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = context.getString(R.string.settings_stub_body),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = context.getString(R.string.settings_logout_spotify))
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(
text = context.getString(R.string.settings_annotation_mode),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_annotation_mode_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = annotationMode,
onCheckedChange = { viewModel.setAnnotationMode(it) },
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(
text = context.getString(R.string.settings_collect_run_data),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_collect_run_data_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = collectRunData,
onCheckedChange = { viewModel.setCollectRunData(it) },
)
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Text(
text = context.getString(R.string.settings_logout_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
modifier = Modifier.padding(top = 12.dp),
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(text = context.getString(R.string.settings_logout_spotify))
}
}
}
}

View File

@@ -0,0 +1,13 @@
package at.lockstep.player.util
import android.Manifest
import android.os.Build
object AudioReadPermission {
fun permissionName(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}

View File

@@ -0,0 +1,101 @@
package at.lockstep.player.util
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
object BeatAnnotationStorage {
/**
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
* Documents/Lockstep/{sessionFolder}/.
*/
fun writeAnnotationsFile(
context: Context,
sessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
contentId: String,
title: String,
artist: String,
beatTimesMs: List<Long>,
): 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,
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 }
return JSONObject().apply {
put("contentId", contentId)
put("title", title)
put("artist", artist)
put("beatTimesSec", JSONArray(sec))
}
}
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. */
fun mp3DocumentContentId(localUri: String?): String {
if (localUri.isNullOrBlank()) {
return ""
}
val u = Uri.parse(localUri)
return try {
DocumentsContract.getDocumentId(u)
} catch (_: Exception) {
u.lastPathSegment ?: ""
}
}
}

View File

@@ -0,0 +1,60 @@
package at.lockstep.player.util
import android.content.ContentUris
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import android.util.Log
object MediaStoreMp3Scanner {
private const val TAG = "LockstepPairing"
fun listMp3Candidates(context: Context): List<Mp3FolderCandidate> {
val resolver = context.applicationContext.contentResolver
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
@Suppress("DEPRECATION")
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
val projection =
arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
)
val selection =
"(${MediaStore.Audio.Media.MIME_TYPE} = ? OR " +
"${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?)"
val selectionArgs = arrayOf("audio/mpeg", "%.mp3")
val out = mutableListOf<Mp3FolderCandidate>()
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val displayName = cursor.getString(nameCol).orEmpty()
val title = cursor.getString(titleCol)?.trim()?.takeIf { it.isNotEmpty() }
val artist = cursor.getString(artistCol)?.trim()?.takeIf { it.isNotEmpty() }
out.add(
Mp3FolderCandidate(
uri = ContentUris.withAppendedId(collection, id),
fileBaseName = displayName.substringBeforeLast('.'),
id3Title = title,
id3Artist = artist,
),
)
}
} ?: Log.w(TAG, "MediaStore audio query returned null")
Log.d(TAG, "MediaStoreMp3Scanner found mp3Count=${out.size}")
return out.distinctBy { it.uri }.sortedBy { it.fileBaseName.lowercase() }
}
}

View File

@@ -0,0 +1,24 @@
package at.lockstep.player.util
data class RunAccelSample(
/** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */
val timestampNanos: Long,
/** ExoPlayer position in ms when this sample was taken — frozen while paused. */
val positionMs: Long,
val values: FloatArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RunAccelSample) return false
return timestampNanos == other.timestampNanos &&
positionMs == other.positionMs &&
values.contentEquals(other.values)
}
override fun hashCode(): Int {
var result = timestampNanos.hashCode()
result = 31 * result + positionMs.hashCode()
result = 31 * result + values.contentHashCode()
return result
}
}

View File

@@ -0,0 +1,228 @@
package at.lockstep.player.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.core.content.ContextCompat
class RunDataCollector(
context: Context,
) {
private val appContext = context.applicationContext
private val sensorManager = appContext.getSystemService(SensorManager::class.java)
private val locationManager = appContext.getSystemService(LocationManager::class.java)
private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private val gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
private val handlerThread = HandlerThread("RunDataCollect").apply { start() }
private val handler = Handler(handlerThread.looper)
private val accelBuffer = mutableListOf<RunAccelSample>()
private val gyroBuffer = mutableListOf<RunDataSample>()
private val gpsBuffer = mutableListOf<RunGpsSample>()
/** Baseline sensor/GPS time for the current song; set on the first sample after [markSongStart]. */
private var songStartElapsedRealtimeNanos: Long? = null
@Volatile
private var collectingEnabled = false
@Volatile
private var playbackPositionMsProvider: () -> Long = { 0L }
private var sensorsRegistered = false
private var locationRegistered = false
private val sensorListener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (!collectingEnabled) return
val timestamp = relativeTimestampNanos(event.timestamp) ?: return
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
val sample =
RunAccelSample(
timestampNanos = timestamp,
positionMs = playbackPositionMsProvider(),
values = floatArrayOf(event.values[0], event.values[1], event.values[2]),
)
synchronized(accelBuffer) {
accelBuffer.add(sample)
}
}
Sensor.TYPE_GYROSCOPE -> {
val sample =
RunDataSample(
timestampNanos = timestamp,
values = floatArrayOf(event.values[0], event.values[1], event.values[2]),
)
synchronized(gyroBuffer) {
gyroBuffer.add(sample)
}
}
}
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
private val locationListener =
LocationListener { location ->
if (!collectingEnabled) return@LocationListener
recordGpsLocation(location)
}
fun start(enableLocation: Boolean) {
startSensors()
if (enableLocation) {
startLocationUpdates()
}
}
private fun startSensors() {
if (sensorsRegistered || sensorManager == null) return
accelerometer?.let {
sensorManager.registerListener(
sensorListener,
it,
SensorManager.SENSOR_DELAY_GAME,
0,
handler,
)
}
gyroscope?.let {
sensorManager.registerListener(
sensorListener,
it,
SensorManager.SENSOR_DELAY_GAME,
0,
handler,
)
}
sensorsRegistered = accelerometer != null || gyroscope != null
}
private fun startLocationUpdates() {
if (locationRegistered || locationManager == null) return
if (!hasLocationPermission()) return
val providers =
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { locationManager.isProviderEnabled(it) }
if (providers.isEmpty()) return
for (provider in providers) {
locationManager.requestLocationUpdates(
provider,
GPS_MIN_TIME_MS,
0f,
locationListener,
handler.looper,
)
}
locationRegistered = true
}
fun stop() {
stopLocationUpdates()
stopSensors()
}
private fun stopSensors() {
if (!sensorsRegistered || sensorManager == null) return
sensorManager.unregisterListener(sensorListener)
sensorsRegistered = false
}
private fun stopLocationUpdates() {
if (!locationRegistered || locationManager == null) return
locationManager.removeUpdates(locationListener)
locationRegistered = false
}
fun release() {
stop()
handlerThread.quitSafely()
}
fun markSongStart() {
songStartElapsedRealtimeNanos = null
}
fun setCollectingEnabled(enabled: Boolean) {
collectingEnabled = enabled
}
fun setPlaybackPositionMsProvider(provider: () -> Long) {
playbackPositionMsProvider = provider
}
fun snapshotAndClear(): RunTrackDataSnapshot =
RunTrackDataSnapshot(
accelerometer =
synchronized(accelBuffer) {
accelBuffer.toList().also { accelBuffer.clear() }
},
gyroscope =
synchronized(gyroBuffer) {
gyroBuffer.toList().also { gyroBuffer.clear() }
},
gps =
synchronized(gpsBuffer) {
gpsBuffer.toList().also { gpsBuffer.clear() }
},
)
private fun relativeTimestampNanos(elapsedRealtimeNanos: Long): Long? {
val start =
songStartElapsedRealtimeNanos ?: run {
songStartElapsedRealtimeNanos = elapsedRealtimeNanos
elapsedRealtimeNanos
}
return elapsedRealtimeNanos - start
}
private fun recordGpsLocation(location: Location) {
val elapsedNs =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
location.elapsedRealtimeNanos
} else {
@Suppress("DEPRECATION")
location.time * 1_000_000L
}
val timestamp = relativeTimestampNanos(elapsedNs) ?: return
synchronized(gpsBuffer) {
val last = gpsBuffer.lastOrNull()
if (last != null && timestamp - last.timestampNanos < GPS_MIN_TIME_NS) {
return
}
gpsBuffer.add(
RunGpsSample(
timestampNanos = timestamp,
latitude = location.latitude,
longitude = location.longitude,
altitude = location.altitude,
),
)
}
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
companion object {
private const val GPS_MIN_TIME_MS = 1_000L
private const val GPS_MIN_TIME_NS = 1_000_000_000L
}
}

View File

@@ -0,0 +1,19 @@
package at.lockstep.player.util
data class RunDataSample(
/** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */
val timestampNanos: Long,
val values: FloatArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RunDataSample) return false
return timestampNanos == other.timestampNanos && values.contentEquals(other.values)
}
override fun hashCode(): Int {
var result = timestampNanos.hashCode()
result = 31 * result + values.contentHashCode()
return result
}
}

View File

@@ -0,0 +1,252 @@
package at.lockstep.player.util
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import at.lockstep.player.BuildConfig
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RunDataStorage {
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. */
fun newRunSessionFolderName(): String =
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}/`. */
fun documentsRelativePath(runSessionFolder: String): String =
"${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(
context: Context,
runSessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
metaContentUri: String,
title: String,
artist: String,
snapshot: RunTrackDataSnapshot,
): Uri? {
if (snapshot.isEmpty()) return null
val safeName = sanitizeFileLabel(playlistDisplayName)
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val fileName = "${safeName}_$suffix.json"
val jsonString =
JSONObject()
.apply {
put("data", accelToJsonArray(snapshot.accelerometer))
put("gyro", samplesToJsonArray(snapshot.gyroscope))
put("gps", gpsToJsonArray(snapshot.gps))
put("meta", metaContentUri)
put("title", title)
put("artist", artist)
put("versionCode", BuildConfig.VERSION_CODE)
}.toString()
return writePublicJsonFile(context, 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 {
val array = JSONArray()
for (sample in samples) {
array.put(
JSONObject().apply {
put("timestamp", sample.timestampNanos)
put("positionMs", sample.positionMs)
put(
"values",
JSONArray().apply {
for (v in sample.values) {
put(v.toDouble())
}
},
)
},
)
}
return array
}
private fun samplesToJsonArray(samples: List<RunDataSample>): JSONArray {
val array = JSONArray()
for (sample in samples) {
array.put(
JSONObject().apply {
put("timestamp", sample.timestampNanos)
put(
"values",
JSONArray().apply {
for (v in sample.values) {
put(v.toDouble())
}
},
)
},
)
}
return array
}
private fun gpsToJsonArray(samples: List<RunGpsSample>): JSONArray {
val array = JSONArray()
for (sample in samples) {
array.put(
JSONObject().apply {
put("timestamp", sample.timestampNanos)
put(
"values",
JSONArray().apply {
put(sample.latitude)
put(sample.longitude)
put(sample.altitude)
},
)
},
)
}
return array
}
private fun writeOrReplaceViaMediaStore(
context: Context,
relativePath: String,
fileName: String,
jsonString: String,
): Uri? {
val resolver = context.applicationContext.contentResolver
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 =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePathWithTrailingSlash(relativePath))
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(collection, pending) ?: return null
try {
resolver.openOutputStream(uri)?.use { stream ->
stream.write(jsonString.toByteArray(Charsets.UTF_8))
} ?: return null
val published =
ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}
resolver.update(uri, published, null, null)
return uri
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
}
/** MediaStore stores directory relative paths with a trailing separator. */
private fun relativePathWithTrailingSlash(relativePath: String): String =
if (relativePath.endsWith("/")) relativePath else "$relativePath/"
@Suppress("DEPRECATION")
private fun writeOrReplaceViaPublicDocumentsDir(
relativePath: String,
fileName: String,
jsonString: String,
): Uri? {
val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val lockstepPrefix = "$APP_DIR/"
val subPath =
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()) {
return null
}
val file = File(dir, fileName)
file.writeText(jsonString)
return Uri.fromFile(file)
}
}

View File

@@ -0,0 +1,17 @@
package at.lockstep.player.util
data class RunGpsSample(
val timestampNanos: Long,
val latitude: Double,
val longitude: Double,
val altitude: Double,
)
data class RunTrackDataSnapshot(
val accelerometer: List<RunAccelSample>,
val gyroscope: List<RunDataSample>,
val gps: List<RunGpsSample>,
) {
fun isEmpty(): Boolean =
accelerometer.isEmpty() && gyroscope.isEmpty() && gps.isEmpty()
}

View File

@@ -0,0 +1,41 @@
package at.lockstep.player.util
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
object SafInitialUris {
private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
private const val INITIAL_URI_EXTRA = "android.provider.extra.INITIAL_URI"
/**
* Internal-storage Documents. Uses [StorageManager] on Android 10+ so the system picker
* lands in a choosable folder instead of the blocked volume root on Pixel devices.
*/
fun internalDocuments(context: Context): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val storageManager = context.getSystemService(StorageManager::class.java)
val intent = storageManager.primaryStorageVolume.createOpenDocumentTreeIntent()
val root =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(INITIAL_URI_EXTRA, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(INITIAL_URI_EXTRA)
}
if (root != null) {
val scheme =
root
.toString()
.replace("/root/", "/document/") + "%3A" + "Documents"
return Uri.parse(scheme)
}
}
return DocumentsContract.buildDocumentUri(
EXTERNAL_STORAGE_AUTHORITY,
"primary:Documents",
)
}
}

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

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/ic_launcher_bg" />
</shape>
</item>
</layer-list>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Running shoe, centered in adaptive-icon safe zone -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18,70c0,-4 3,-7 7,-7h4l2,8c8,2 18,3 28,2l32,-6c5,-1 9,2 10,7l1,6c0,4 -3,8 -8,9l-38,7c-16,2 -32,0 -46,-6l-2,-1c-4,-2 -7,-6 -6,-11l4,-8z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M32,52c6,-14 22,-22 38,-20l26,4c8,1 14,8 15,16l1,10c0,3 -2,6 -5,7l-8,2 -30,4c-12,1 -24,-4 -32,-13l-6,-9c-2,-3 -1,-7 1,-10z" />
<path
android:fillAlpha="0.92"
android:fillColor="#FFFFFF"
android:pathData="M48,46l18,-2c4,0 7,3 8,7v2l-20,3c-3,0 -6,-2 -7,-5v-3c0,-1 0,-2 1,-2z" />
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>

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.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#E65100</color>
<color name="ic_launcher_bg">#ffffff</color>
</resources>

View File

@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lockstep</string>
<string name="app_name">Pasada</string>
<string name="cd_previous_track">Previous track</string>
<string name="cd_play_pause">Play or pause</string>
<string name="cd_next_track">Next track</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_next">Next</string>
<string name="notification_play_pause">Play or pause</string>
<string name="notification_loading_playlist">Loading playlist…</string>
<string name="onboarding_title">Welcome to Lockstep</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_title">Welcome to Pasada</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_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_connected">Account linked — you can continue.</string>
<string name="onboarding_continue_signed_in">Continue</string>
@@ -25,17 +25,29 @@
<string name="library_open_playlist">Tap to play (or pair local MP3s)</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_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_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_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_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_beat_count">Beats recorded: %1$d</string>
<string name="annotation_time_ms">%1$d ms</string>
<string name="pairing_title">Pair local MP3s</string>
<string name="pairing_choose_folder">Choose folder of MP3s</string>
<string name="pairing_scan_device">Scan audio on this device</string>
<string name="pairing_scan_device_help">Finds MP3s in Music and other folders without using the folder picker.</string>
<string name="pairing_audio_permission_denied">Audio permission is required to scan MP3s on this device.</string>
<string name="pairing_status_paired">Paired with local file</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_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_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</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()