Compare commits
11 Commits
main
...
12adfbc960
| Author | SHA1 | Date | |
|---|---|---|---|
| 12adfbc960 | |||
| b332c7b2f3 | |||
| 306ad83d12 | |||
| 9d161bd2a9 | |||
| ad2dc18f00 | |||
| 8d1b762b78 | |||
| e936dee142 | |||
| 45c4980c05 | |||
| 7d9654aa7e | |||
| d663a1c9de | |||
| 183efd343e |
67
DESIGN.md
67
DESIGN.md
@@ -2,6 +2,73 @@
|
|||||||
|
|
||||||
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
|
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
|
||||||
|
|
||||||
|
## UI screens
|
||||||
|
|
||||||
|
- Onboarding: explains why notifications are necessary, implements login
|
||||||
|
- Library: shows the playlists of the user
|
||||||
|
- Annotation: user presses a button to annotate the beat
|
||||||
|
- Now Playing: player showing an individual track, with pause/previous/next buttons
|
||||||
|
- Collection mode: app records sensor data during a run
|
||||||
|
- Settings: allows to enable modes above, logout button
|
||||||
|
|
||||||
|
## Data files
|
||||||
|
|
||||||
|
Filesystem layout of JSON files on primary storage (e.g. Pixel 5: `/storage/emulated/0/Documents/Lockstep/`):
|
||||||
|
|
||||||
|
```
|
||||||
|
/storage/emulated/0/Documents/Lockstep/
|
||||||
|
├── 2026-05-31_10-15-30/ ← annotation session (TYPE_ANNOTATION)
|
||||||
|
│ ├── Running Mix_001.json
|
||||||
|
│ └── Running Mix_002.json
|
||||||
|
├── 2026-05-31_14-22-05/ ← now-playing collection session (TYPE_COLLECTION)
|
||||||
|
│ ├── Running Mix_001.json
|
||||||
|
│ └── Running Mix_002.json
|
||||||
|
└── Beats/ ← canonical beats per playlist (TYPE_BEATS)
|
||||||
|
└── Running Mix/
|
||||||
|
├── 001.json
|
||||||
|
└── 002.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each type is recorded in Room `file_metadata` (`FileMetadataEntity`) with `fileUri`, `trackId`, `type`, `version`, `synced`, and `lastSyncedAt` (epoch millis).
|
||||||
|
|
||||||
|
| Type | Constant | Written by | Path pattern | Server sync |
|
||||||
|
|------|----------|------------|--------------|-------------|
|
||||||
|
| **Annotation session** | `TYPE_ANNOTATION` | Annotation screen (`BeatAnnotationStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per annotation session | Yes — uploaded via `POST /metadata` when signed in |
|
||||||
|
| **Run collection** | `TYPE_COLLECTION` | Now Playing with collect-run-data enabled (`RunDataStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per run session | Yes |
|
||||||
|
| **Canonical beats** | `TYPE_BEATS` | Annotation screen (`BeatAnnotationStorage`) — written alongside each session annotation | `Documents/Lockstep/Beats/{playlist_name}/{NNN}.json` — one folder per playlist; **overwrites** on re-annotation | **No** — tracked locally only; `synced = 1` and `lastSyncedAt` set at write time |
|
||||||
|
|
||||||
|
During an **annotation session**, leaving a track writes **both** `TYPE_ANNOTATION` (append-only session archive) and `TYPE_BEATS` (latest beats for that playlist slot).
|
||||||
|
|
||||||
|
### Beat JSON schema (`TYPE_ANNOTATION` and `TYPE_BEATS`)
|
||||||
|
|
||||||
|
Same schema for session annotations and canonical beats:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contentId": "primary:Documents/Music/track.mp3",
|
||||||
|
"title": "Track Title",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"beatTimesSec": [1.23, 2.45, 3.67]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`contentId`** — SAF document id of the paired local MP3 when available; otherwise Spotify track id.
|
||||||
|
- **`beatTimesSec`** — user-tapped beat times in seconds (playback position).
|
||||||
|
|
||||||
|
### Collection JSON schema (`TYPE_COLLECTION`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [ { "timestamp": 0, "positionMs": 0, "values": [0.0, 0.0, 9.8] } ],
|
||||||
|
"gyro": [ { "timestamp": 0, "values": [0.0, 0.0, 0.0] } ],
|
||||||
|
"gps": [ { "timestamp": 0, "values": [48.2, 16.3, 200.0] } ],
|
||||||
|
"meta": "content://…",
|
||||||
|
"title": "Track Title",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"versionCode": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Already captured from SPECS.md
|
## Already captured from SPECS.md
|
||||||
|
|
||||||
- **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback.
|
- **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Android prototype: music playback adapts to running pace (accelerometer + native
|
|||||||
|
|
||||||
**App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/).
|
**App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/).
|
||||||
|
|
||||||
|
**API:** [`lockstep-2-api/`](lockstep-2-api/) — `api.py` contains the Python API that is deployed on the server behind api.lockstep.at
|
||||||
|
|
||||||
Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studio’s **jbr** under `/Applications/…` or `/c/Program Files/…`.
|
Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studio’s **jbr** under `/Applications/…` or `/c/Program Files/…`.
|
||||||
|
|
||||||
Submodule: [`jukebox/`](jukebox/).
|
Submodule: [`jukebox/`](jukebox/).
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".LockstepApplication"
|
android:name=".LockstepApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/ic_launcher_layer"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@drawable/ic_launcher_layer"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LockstepPlayer">
|
android:theme="@style/Theme.LockstepPlayer">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import android.app.NotificationManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import at.lockstep.jukebox.Jukebox
|
import at.lockstep.jukebox.Jukebox
|
||||||
import at.lockstep.jukebox.PlaylistRepository
|
import at.lockstep.jukebox.PlaylistRepository
|
||||||
|
import at.lockstep.player.data.MetadataSyncClient
|
||||||
import at.lockstep.player.data.db.AppDatabase
|
import at.lockstep.player.data.db.AppDatabase
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
class LockstepApplication : Application() {
|
class LockstepApplication : Application() {
|
||||||
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
|
|||||||
|
|
||||||
val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
|
val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
|
||||||
|
|
||||||
|
val metadataSyncClient: MetadataSyncClient by lazy {
|
||||||
|
MetadataSyncClient(
|
||||||
|
OkHttpClient(),
|
||||||
|
BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val playlistRepository: PlaylistRepository by lazy {
|
val playlistRepository: PlaylistRepository by lazy {
|
||||||
Jukebox.playlistRepository(
|
Jukebox.playlistRepository(
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import at.lockstep.jukebox.api.LockstepApiException
|
import at.lockstep.jukebox.api.LockstepApiException
|
||||||
import at.lockstep.jukebox.db.TrackRow
|
import at.lockstep.jukebox.db.TrackRow
|
||||||
import at.lockstep.player.data.UserPreferencesRepository
|
import at.lockstep.player.data.UserPreferencesRepository
|
||||||
|
import at.lockstep.player.data.db.FileMetadataEntity
|
||||||
import at.lockstep.player.data.db.TrackPairingEntity
|
import at.lockstep.player.data.db.TrackPairingEntity
|
||||||
import at.lockstep.player.util.AudioUriValidator
|
import at.lockstep.player.util.AudioUriValidator
|
||||||
import at.lockstep.player.playback.TrackBoundaryEvent
|
import at.lockstep.player.playback.TrackBoundaryEvent
|
||||||
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ class LockstepViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "LockstepPairing"
|
private const val TAG = "LockstepPairing"
|
||||||
|
private const val METADATA_TAG = "LockstepMetadata"
|
||||||
|
|
||||||
/** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
|
/** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
|
||||||
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
|
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
|
||||||
@@ -46,6 +50,7 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
private val prefs = UserPreferencesRepository(application)
|
private val prefs = UserPreferencesRepository(application)
|
||||||
private val pairingDao get() = app.database.pairingDao()
|
private val pairingDao get() = app.database.pairingDao()
|
||||||
|
private val fileMetadataDao get() = app.database.fileMetadataDao()
|
||||||
|
|
||||||
val onboardingComplete: StateFlow<Boolean> =
|
val onboardingComplete: StateFlow<Boolean> =
|
||||||
prefs.onboardingComplete.stateIn(
|
prefs.onboardingComplete.stateIn(
|
||||||
@@ -162,12 +167,14 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described
|
* Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
|
||||||
* by [event]. Skips when [beatTimesMs] is empty.
|
* and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
|
||||||
|
* Skips when [beatTimesMs] is empty.
|
||||||
*/
|
*/
|
||||||
fun persistBeatAnnotation(
|
fun persistBeatAnnotation(
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
playlistDisplayName: String,
|
playlistDisplayName: String,
|
||||||
|
sessionFolder: String,
|
||||||
event: TrackBoundaryEvent,
|
event: TrackBoundaryEvent,
|
||||||
beatTimesMs: List<Long>,
|
beatTimesMs: List<Long>,
|
||||||
) {
|
) {
|
||||||
@@ -178,15 +185,75 @@ class LockstepViewModel(
|
|||||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||||
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
||||||
val contentId = docId.ifBlank { event.trackId }
|
val contentId = docId.ifBlank { event.trackId }
|
||||||
|
val appContext = getApplication<Application>()
|
||||||
|
val annotationUri =
|
||||||
BeatAnnotationStorage.writeAnnotationsFile(
|
BeatAnnotationStorage.writeAnnotationsFile(
|
||||||
context = getApplication(),
|
context = appContext,
|
||||||
|
sessionFolder = sessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.queueIndex,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
beatTimesMs = beatTimesMs,
|
beatTimesMs = beatTimesMs,
|
||||||
|
) ?: return@launch
|
||||||
|
recordMetadataEntry(
|
||||||
|
fileUri = annotationUri.toString(),
|
||||||
|
trackId = event.trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_ANNOTATION,
|
||||||
)
|
)
|
||||||
|
val beatsUri =
|
||||||
|
BeatAnnotationStorage.writeBeatsFile(
|
||||||
|
context = appContext,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = event.queueIndex,
|
||||||
|
contentId = contentId,
|
||||||
|
title = event.title,
|
||||||
|
artist = event.artist,
|
||||||
|
beatTimesMs = beatTimesMs,
|
||||||
|
) ?: return@launch
|
||||||
|
recordOrUpdateMetadataEntry(
|
||||||
|
fileUri = beatsUri.toString(),
|
||||||
|
trackId = event.trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recordMetadataEntry(
|
||||||
|
fileUri: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val beats = type == FileMetadataEntity.TYPE_BEATS
|
||||||
|
val entry =
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = fileUri,
|
||||||
|
trackId = trackId,
|
||||||
|
type = type,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = beats,
|
||||||
|
lastSyncedAt = if (beats) now else null,
|
||||||
|
)
|
||||||
|
val id = fileMetadataDao.insert(entry)
|
||||||
|
if (!beats) {
|
||||||
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recordOrUpdateMetadataEntry(
|
||||||
|
fileUri: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
) {
|
||||||
|
val version = BuildConfig.VERSION_CODE
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val existing = fileMetadataDao.findByTrackIdAndType(trackId, type)
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.updateFile(existing.id, fileUri, version, now)
|
||||||
|
} else {
|
||||||
|
recordMetadataEntry(fileUri, trackId, type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +274,11 @@ class LockstepViewModel(
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||||
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
RunDataStorage.writeRunDataFile(
|
writeRunDataAndRecordMetadata(
|
||||||
context = getApplication(),
|
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.queueIndex,
|
||||||
|
trackId = event.trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
@@ -237,11 +304,11 @@ class LockstepViewModel(
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val pairing = pairingDao.findForTrack(playlistId, trackId)
|
val pairing = pairingDao.findForTrack(playlistId, trackId)
|
||||||
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
RunDataStorage.writeRunDataFile(
|
writeRunDataAndRecordMetadata(
|
||||||
context = getApplication(),
|
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = queueIndex,
|
trackQueueIndex0Based = queueIndex,
|
||||||
|
trackId = trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = title,
|
title = title,
|
||||||
artist = artist,
|
artist = artist,
|
||||||
@@ -250,6 +317,99 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun writeRunDataAndRecordMetadata(
|
||||||
|
runSessionFolder: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
trackId: String,
|
||||||
|
metaContentUri: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
snapshot: RunTrackDataSnapshot,
|
||||||
|
) {
|
||||||
|
val uri =
|
||||||
|
RunDataStorage.writeRunDataFile(
|
||||||
|
context = getApplication(),
|
||||||
|
runSessionFolder = runSessionFolder,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = trackQueueIndex0Based,
|
||||||
|
metaContentUri = metaContentUri,
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
snapshot = snapshot,
|
||||||
|
) ?: return
|
||||||
|
val entry =
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = uri.toString(),
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_COLLECTION,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
)
|
||||||
|
val id = fileMetadataDao.insert(entry)
|
||||||
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncPendingMetadata(): String? {
|
||||||
|
val token = spotifyAccessToken.value
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val pending = fileMetadataDao.listUnsynced()
|
||||||
|
if (pending.isEmpty()) {
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val failures = pending.count { entry -> !syncMetadataEntry(entry) }
|
||||||
|
if (failures > 0) {
|
||||||
|
"Failed to sync $failures file(s)"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncMetadataEntry(entry: FileMetadataEntity): Boolean {
|
||||||
|
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
val collection =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
readCollectionJson(entry.fileUri)
|
||||||
|
} ?: return false
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
app.metadataSyncClient.uploadCollection(
|
||||||
|
accessToken = token,
|
||||||
|
trackId = entry.trackId,
|
||||||
|
type = entry.type,
|
||||||
|
version = entry.version,
|
||||||
|
collection = collection,
|
||||||
|
)
|
||||||
|
fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
|
||||||
|
true
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCollectionJson(fileUri: String): JSONObject? {
|
||||||
|
val uri = Uri.parse(fileUri)
|
||||||
|
try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
return JSONObject(stream.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(METADATA_TAG, "readCollectionJson contentResolver failed uri=$fileUri", e)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val path = uri.path ?: return null
|
||||||
|
JSONObject(File(path).readText())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(METADATA_TAG, "readCollectionJson file path failed uri=$fileUri", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncJukeboxIfToken(): String? {
|
suspend fun syncJukeboxIfToken(): String? {
|
||||||
val token = spotifyAccessToken.value
|
val token = spotifyAccessToken.value
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package at.lockstep.player.data
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class MetadataSyncClient(
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) {
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class UserPreferencesRepository(
|
|||||||
prefs[KEY_ANNOTATION_MODE] == true
|
prefs[KEY_ANNOTATION_MODE] == true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */
|
/** When true, Now Playing records sensor samples per track into JSON under Documents/Lockstep/{sessionFolder}/. */
|
||||||
val collectRunData: Flow<Boolean> =
|
val collectRunData: Flow<Boolean> =
|
||||||
dataStore.data.map { prefs ->
|
dataStore.data.map { prefs ->
|
||||||
prefs[KEY_COLLECT_RUN_DATA] == true
|
prefs[KEY_COLLECT_RUN_DATA] == true
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ import androidx.room.Room
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [TrackPairingEntity::class],
|
entities = [
|
||||||
version = 2,
|
TrackPairingEntity::class,
|
||||||
|
FileMetadataEntity::class,
|
||||||
|
],
|
||||||
|
version = 5,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase :
|
abstract class AppDatabase :
|
||||||
RoomDatabase() {
|
RoomDatabase() {
|
||||||
abstract fun pairingDao(): PairingDao
|
abstract fun pairingDao(): PairingDao
|
||||||
|
|
||||||
|
abstract fun fileMetadataDao(): FileMetadataDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DB_NAME = "lockstep.db"
|
private const val DB_NAME = "lockstep.db"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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 fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt WHERE id = :id",
|
||||||
|
)
|
||||||
|
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal file
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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("pasada");
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
|
||||||
|
void onError(int errorCode, String message);
|
||||||
|
}
|
||||||
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal file
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,11 @@ import android.support.v4.media.session.MediaSessionCompat
|
|||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.media.app.NotificationCompat.MediaStyle
|
import androidx.media.app.NotificationCompat.MediaStyle
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import at.lockstep.player.LockstepApplication
|
import at.lockstep.player.LockstepApplication
|
||||||
import at.lockstep.player.MainActivity
|
import at.lockstep.player.MainActivity
|
||||||
import at.lockstep.player.R
|
import at.lockstep.player.R
|
||||||
|
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
|
||||||
|
import at.lockstep.player.playback.engine.MusicPlayerEngine
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -36,10 +35,9 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use
|
* Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
|
||||||
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is
|
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
|
||||||
* the same timeline used for seeking and end-of-track reporting — see Media3
|
* current media item).
|
||||||
* [Player] / [ExoPlayer] documentation.
|
|
||||||
*/
|
*/
|
||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
@@ -48,7 +46,7 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
private var player: ExoPlayer? = null
|
private var engine: MusicPlayerEngine? = null
|
||||||
private var positionPollJob: Job? = null
|
private var positionPollJob: Job? = null
|
||||||
private var positionCachePollJob: Job? = null
|
private var positionCachePollJob: Job? = null
|
||||||
|
|
||||||
@@ -64,15 +62,15 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private var queue: List<TrackQueueItem> = emptyList()
|
private var queue: List<TrackQueueItem> = emptyList()
|
||||||
private var index: Int = 0
|
private var index: Int = 0
|
||||||
|
private var tornDown = false
|
||||||
|
|
||||||
/** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */
|
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var cachedPlaybackPositionMs: Long = 0L
|
private var cachedPlaybackPositionMs: Long = 0L
|
||||||
|
|
||||||
private val playerListener =
|
private val engineListener =
|
||||||
object : Player.Listener {
|
object : MusicPlayerEngine.Listener {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackEnded() {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -91,6 +89,12 @@ class PlaybackService : Service() {
|
|||||||
setPlaying(false)
|
setPlaying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onError(
|
||||||
|
errorCode: Int,
|
||||||
|
message: String,
|
||||||
|
) {
|
||||||
|
setPlaying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +146,8 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
val p = player ?: return
|
engine?.seekTo(pos)
|
||||||
p.seekTo(pos)
|
updateProgressFromEngine()
|
||||||
updateProgressFromPlayer()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -196,15 +199,15 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePlayer(): ExoPlayer {
|
private fun ensureEngine(): MusicPlayerEngine {
|
||||||
player?.let {
|
engine?.let {
|
||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
return ExoPlayer.Builder(this)
|
return ExoPlayerMusicPlayerEngine(this)
|
||||||
.build()
|
|
||||||
.also {
|
.also {
|
||||||
it.addListener(playerListener)
|
it.setListener(engineListener)
|
||||||
player = it
|
it.initSession()
|
||||||
|
engine = it
|
||||||
startPositionPolling()
|
startPositionPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,8 +218,8 @@ class PlaybackService : Service() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(UPDATE_INTERVAL_MS)
|
delay(UPDATE_INTERVAL_MS)
|
||||||
if (player != null && queue.isNotEmpty()) {
|
if (engine != null && queue.isNotEmpty()) {
|
||||||
updateProgressFromPlayer()
|
updateProgressFromEngine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,14 +233,14 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releasePlayer() {
|
private fun releaseEngine() {
|
||||||
positionPollJob?.cancel()
|
positionPollJob?.cancel()
|
||||||
positionPollJob = null
|
positionPollJob = null
|
||||||
positionCachePollJob?.cancel()
|
positionCachePollJob?.cancel()
|
||||||
positionCachePollJob = null
|
positionCachePollJob = null
|
||||||
player?.removeListener(playerListener)
|
engine?.setListener(null)
|
||||||
player?.release()
|
engine?.releaseSession()
|
||||||
player = null
|
engine = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPlaylist(pid: String) {
|
private fun startPlaylist(pid: String) {
|
||||||
@@ -267,14 +270,14 @@ class PlaybackService : Service() {
|
|||||||
index = 0
|
index = 0
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
releasePlayer()
|
releaseEngine()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
ensurePlayer()
|
ensureEngine()
|
||||||
setPlaying(true)
|
setPlaying(true)
|
||||||
publishCurrentTrack()
|
publishCurrentTrack()
|
||||||
refreshForegroundNotification()
|
refreshForegroundNotification()
|
||||||
@@ -286,7 +289,7 @@ class PlaybackService : Service() {
|
|||||||
val item = queue.getOrNull(index) ?: return
|
val item = queue.getOrNull(index) ?: return
|
||||||
applyCurrentMediaItem(item)
|
applyCurrentMediaItem(item)
|
||||||
val durationSec =
|
val durationSec =
|
||||||
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
|
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
|
||||||
?: (item.durationMsHint / 1000).coerceAtLeast(1)
|
?: (item.durationMsHint / 1000).coerceAtLeast(1)
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
PlaybackUiState(
|
PlaybackUiState(
|
||||||
@@ -299,18 +302,21 @@ class PlaybackService : Service() {
|
|||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updateProgressFromPlayer()
|
updateProgressFromEngine()
|
||||||
updateSessionMetadata(item, durationSec)
|
updateSessionMetadata(item, durationSec)
|
||||||
updatePlaybackStateFromPlayer()
|
updatePlaybackStateFromEngine()
|
||||||
refreshForegroundNotification()
|
refreshForegroundNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyCurrentMediaItem(item: TrackQueueItem) {
|
private fun applyCurrentMediaItem(item: TrackQueueItem) {
|
||||||
val uri = item.localUri ?: return
|
val uri = item.localUri ?: return
|
||||||
val p = ensurePlayer()
|
val e = ensureEngine()
|
||||||
p.setMediaItem(MediaItem.fromUri(uri))
|
e.prepareTrack(uri)
|
||||||
p.prepare()
|
if (_uiState.value.isPlaying) {
|
||||||
p.playWhenReady = _uiState.value.isPlaying
|
e.play()
|
||||||
|
} else {
|
||||||
|
e.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSessionMetadata(
|
private fun updateSessionMetadata(
|
||||||
@@ -327,27 +333,26 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun currentDurationMs(): Long {
|
private fun currentDurationMs(): Long {
|
||||||
val p = player
|
val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
|
||||||
val fromPlayer = p?.duration?.takeIf { it > 0 }
|
if (fromEngine != null) {
|
||||||
if (fromPlayer != null) {
|
return fromEngine
|
||||||
return fromPlayer
|
|
||||||
}
|
}
|
||||||
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
|
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
|
||||||
return hint
|
return hint
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshCachedPlaybackPositionMs() {
|
private fun refreshCachedPlaybackPositionMs() {
|
||||||
val p = player ?: return
|
val e = engine ?: return
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
cachedPlaybackPositionMs = 0L
|
cachedPlaybackPositionMs = 0L
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
||||||
cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs)
|
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateProgressFromPlayer() {
|
private fun updateProgressFromEngine() {
|
||||||
val p = player ?: return
|
val e = engine ?: return
|
||||||
refreshCachedPlaybackPositionMs()
|
refreshCachedPlaybackPositionMs()
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
return
|
return
|
||||||
@@ -364,12 +369,12 @@ class PlaybackService : Service() {
|
|||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updatePlaybackStateFromPlayer()
|
updatePlaybackStateFromEngine()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaybackStateFromPlayer() {
|
private fun updatePlaybackStateFromEngine() {
|
||||||
val p = player
|
val e = engine
|
||||||
val positionMs = p?.currentPosition ?: 0L
|
val positionMs = e?.getCurrentPositionMs() ?: 0L
|
||||||
val actions =
|
val actions =
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
@@ -377,7 +382,7 @@ class PlaybackService : Service() {
|
|||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||||
PlaybackStateCompat.ACTION_SEEK_TO
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
val state =
|
val state =
|
||||||
if (_uiState.value.isPlaying && p?.isPlaying == true) {
|
if (_uiState.value.isPlaying && e?.isPlaying() == true) {
|
||||||
PlaybackStateCompat.STATE_PLAYING
|
PlaybackStateCompat.STATE_PLAYING
|
||||||
} else {
|
} else {
|
||||||
PlaybackStateCompat.STATE_PAUSED
|
PlaybackStateCompat.STATE_PAUSED
|
||||||
@@ -392,8 +397,12 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private fun setPlaying(playing: Boolean) {
|
private fun setPlaying(playing: Boolean) {
|
||||||
_uiState.value = _uiState.value.copy(isPlaying = playing)
|
_uiState.value = _uiState.value.copy(isPlaying = playing)
|
||||||
player?.playWhenReady = playing
|
if (playing) {
|
||||||
updatePlaybackStateFromPlayer()
|
engine?.play()
|
||||||
|
} else {
|
||||||
|
engine?.pause()
|
||||||
|
}
|
||||||
|
updatePlaybackStateFromEngine()
|
||||||
refreshForegroundNotification()
|
refreshForegroundNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,15 +439,15 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
fun requestSeek(fraction: Float) {
|
fun requestSeek(fraction: Float) {
|
||||||
if (queue.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
val p = player ?: return
|
val e = engine ?: return
|
||||||
val durationMs = currentDurationMs()
|
val durationMs = currentDurationMs()
|
||||||
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
|
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
|
||||||
p.seekTo(positionMs)
|
e.seekTo(positionMs)
|
||||||
updateProgressFromPlayer()
|
updateProgressFromEngine()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
|
* 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.
|
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
|
||||||
*/
|
*/
|
||||||
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
|
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
|
||||||
@@ -516,15 +525,37 @@ class PlaybackService : Service() {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
stopPlaybackAndTeardown()
|
||||||
|
stopSelf()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
stopPlaybackAndTeardown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
|
||||||
|
private fun stopPlaybackAndTeardown() {
|
||||||
|
if (tornDown) return
|
||||||
|
tornDown = true
|
||||||
positionPollJob?.cancel()
|
positionPollJob?.cancel()
|
||||||
|
positionPollJob = null
|
||||||
positionCachePollJob?.cancel()
|
positionCachePollJob?.cancel()
|
||||||
releasePlayer()
|
positionCachePollJob = null
|
||||||
|
releaseEngine()
|
||||||
|
queue = emptyList()
|
||||||
|
index = 0
|
||||||
|
cachedPlaybackPositionMs = 0L
|
||||||
|
_uiState.value = PlaybackUiState.initial()
|
||||||
|
if (::mediaSession.isInitialized) {
|
||||||
mediaSession.run {
|
mediaSession.run {
|
||||||
isActive = false
|
isActive = false
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlaybackUiState(
|
data class PlaybackUiState(
|
||||||
@@ -557,7 +588,7 @@ class PlaybackService : Service() {
|
|||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
val localUri: Uri?,
|
val localUri: Uri?,
|
||||||
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
|
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||||
val durationMsHint: Int,
|
val durationMsHint: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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 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() {
|
||||||
|
pendingStart = false
|
||||||
|
mainHandler.post {
|
||||||
|
listener?.onPlaybackEnded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(
|
||||||
|
errorCode: Int,
|
||||||
|
message: String,
|
||||||
|
) {
|
||||||
|
mainHandler.post {
|
||||||
|
listener?.onError(errorCode, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initSession() {
|
||||||
|
if (sessionInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LibPasada.loadNative()
|
||||||
|
LibPasada.setPlaybackListener(nativeListener)
|
||||||
|
LibPasada.init()
|
||||||
|
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
|
||||||
|
LibPasada.play(fd, trackOffset, trackLength)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -32,11 +33,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.lockstep.player.LockstepViewModel
|
import at.lockstep.player.LockstepViewModel
|
||||||
import at.lockstep.player.R
|
import at.lockstep.player.R
|
||||||
import at.lockstep.player.playback.PlaybackService
|
import at.lockstep.player.playback.PlaybackService
|
||||||
|
import at.lockstep.player.util.RunDataStorage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
|
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
|
||||||
@@ -53,6 +56,7 @@ fun AnnotationRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val beatListHeight = LocalConfiguration.current.screenHeightDp.dp / 5
|
||||||
var ui by remember {
|
var ui by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
NowPlayingUiState(
|
NowPlayingUiState(
|
||||||
@@ -82,6 +86,7 @@ fun AnnotationRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val beatTimesMs = remember { mutableStateListOf<Long>() }
|
val beatTimesMs = remember { mutableStateListOf<Long>() }
|
||||||
|
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||||
|
|
||||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
|
|
||||||
@@ -89,12 +94,18 @@ fun AnnotationRoute(
|
|||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback, playlistId, playlistDisplayName) {
|
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
service.trackBoundaryEvents.collect { event ->
|
service.trackBoundaryEvents.collect { event ->
|
||||||
val snapshot = beatTimesMs.toList()
|
val snapshot = beatTimesMs.toList()
|
||||||
beatTimesMs.clear()
|
beatTimesMs.clear()
|
||||||
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot)
|
viewModel.persistBeatAnnotation(
|
||||||
|
playlistId,
|
||||||
|
playlistDisplayName,
|
||||||
|
annotationSessionFolder,
|
||||||
|
event,
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +185,7 @@ fun AnnotationRoute(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 160.dp)
|
.height(beatListHeight)
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -49,9 +49,13 @@ fun LibraryScreen(
|
|||||||
|
|
||||||
LaunchedEffect(token) {
|
LaunchedEffect(token) {
|
||||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||||
val err = viewModel.syncJukeboxIfToken()
|
val errors =
|
||||||
if (err != null) {
|
listOfNotNull(
|
||||||
Toast.makeText(context, err, Toast.LENGTH_LONG).show()
|
viewModel.syncJukeboxIfToken(),
|
||||||
|
viewModel.syncPendingMetadata(),
|
||||||
|
)
|
||||||
|
if (errors.isNotEmpty()) {
|
||||||
|
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -50,6 +51,7 @@ fun OnboardingScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -107,16 +107,23 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { viewModel.setCollectRunData(it) },
|
onCheckedChange = { viewModel.setCollectRunData(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.settings_stub_body),
|
text = context.getString(R.string.settings_logout_title),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.settings_logout_spotify_help),
|
text = context.getString(R.string.settings_logout_spotify_help),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
|
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
|
||||||
colors =
|
colors =
|
||||||
ButtonDefaults.buttonColors(
|
ButtonDefaults.buttonColors(
|
||||||
@@ -129,3 +136,4 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,20 +5,34 @@ import android.net.Uri
|
|||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
object BeatAnnotationStorage {
|
object BeatAnnotationStorage {
|
||||||
|
|
||||||
private const val DIR_NAME = "beat_annotations"
|
|
||||||
|
|
||||||
fun annotationsDir(context: Context): File =
|
|
||||||
File(context.filesDir, DIR_NAME).apply { mkdirs() }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json".
|
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
|
||||||
|
* Documents/Lockstep/{sessionFolder}/.
|
||||||
*/
|
*/
|
||||||
fun writeAnnotationsFile(
|
fun writeAnnotationsFile(
|
||||||
|
context: Context,
|
||||||
|
sessionFolder: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
contentId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
beatTimesMs: List<Long>,
|
||||||
|
): Uri? {
|
||||||
|
val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based)
|
||||||
|
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
|
||||||
|
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/.
|
||||||
|
* Overwrites an existing file for the same track index.
|
||||||
|
*/
|
||||||
|
fun writeBeatsFile(
|
||||||
context: Context,
|
context: Context,
|
||||||
playlistDisplayName: String,
|
playlistDisplayName: String,
|
||||||
trackQueueIndex0Based: Int,
|
trackQueueIndex0Based: Int,
|
||||||
@@ -26,26 +40,36 @@ object BeatAnnotationStorage {
|
|||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
beatTimesMs: List<Long>,
|
beatTimesMs: List<Long>,
|
||||||
): File {
|
): Uri? {
|
||||||
val safeName =
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
playlistDisplayName
|
val fileName = "$suffix.json"
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
|
||||||
.trim()
|
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
|
||||||
.ifBlank { "playlist" }
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
.take(120)
|
}
|
||||||
val suffix =
|
|
||||||
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
fun buildAnnotationJson(
|
||||||
val file = File(annotationsDir(context), "${safeName}_$suffix.json")
|
contentId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
beatTimesMs: List<Long>,
|
||||||
|
): JSONObject {
|
||||||
val sec = beatTimesMs.map { it / 1000.0 }
|
val sec = beatTimesMs.map { it / 1000.0 }
|
||||||
val json =
|
return JSONObject().apply {
|
||||||
JSONObject().apply {
|
|
||||||
put("contentId", contentId)
|
put("contentId", contentId)
|
||||||
put("title", title)
|
put("title", title)
|
||||||
put("artist", artist)
|
put("artist", artist)
|
||||||
put("beatTimesSec", JSONArray(sec))
|
put("beatTimesSec", JSONArray(sec))
|
||||||
}
|
}
|
||||||
file.writeText(json.toString(2))
|
}
|
||||||
return file
|
|
||||||
|
private fun trackFileName(
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
): String {
|
||||||
|
val safeName = RunDataStorage.sanitizeFileLabel(playlistDisplayName)
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
return "${safeName}_$suffix.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
||||||
|
|||||||
24
app/src/main/java/at/lockstep/player/util/RunAccelSample.kt
Normal file
24
app/src/main/java/at/lockstep/player/util/RunAccelSample.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.util
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -16,15 +17,27 @@ import java.util.Locale
|
|||||||
|
|
||||||
object RunDataStorage {
|
object RunDataStorage {
|
||||||
private const val APP_DIR = "Lockstep"
|
private const val APP_DIR = "Lockstep"
|
||||||
|
private const val BEATS_DIR = "Beats"
|
||||||
|
|
||||||
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
||||||
fun newRunSessionFolderName(): String =
|
fun newRunSessionFolderName(): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
||||||
|
|
||||||
|
fun sanitizeFileLabel(name: String): String =
|
||||||
|
name
|
||||||
|
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
.trim()
|
||||||
|
.ifBlank { "playlist" }
|
||||||
|
.take(120)
|
||||||
|
|
||||||
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
||||||
fun documentsRelativePath(runSessionFolder: String): String =
|
fun documentsRelativePath(runSessionFolder: String): String =
|
||||||
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
||||||
|
|
||||||
|
/** Public path segment: `Documents/Lockstep/Beats/{playlist_name}/`. */
|
||||||
|
fun beatsPlaylistRelativePath(playlistDisplayName: String): String =
|
||||||
|
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$BEATS_DIR/${sanitizeFileLabel(playlistDisplayName)}"
|
||||||
|
|
||||||
fun writeRunDataFile(
|
fun writeRunDataFile(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
runSessionFolder: String,
|
||||||
@@ -37,12 +50,7 @@ object RunDataStorage {
|
|||||||
): Uri? {
|
): Uri? {
|
||||||
if (snapshot.isEmpty()) return null
|
if (snapshot.isEmpty()) return null
|
||||||
|
|
||||||
val safeName =
|
val safeName = sanitizeFileLabel(playlistDisplayName)
|
||||||
playlistDisplayName
|
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
|
||||||
.trim()
|
|
||||||
.ifBlank { "playlist" }
|
|
||||||
.take(120)
|
|
||||||
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
val fileName = "${safeName}_$suffix.json"
|
val fileName = "${safeName}_$suffix.json"
|
||||||
|
|
||||||
@@ -58,11 +66,34 @@ object RunDataStorage {
|
|||||||
put("versionCode", BuildConfig.VERSION_CODE)
|
put("versionCode", BuildConfig.VERSION_CODE)
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
|
||||||
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
|
|
||||||
} else {
|
|
||||||
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */
|
||||||
|
fun writePublicJsonFile(
|
||||||
|
context: Context,
|
||||||
|
sessionFolder: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? =
|
||||||
|
writeOrReplacePublicJsonFile(
|
||||||
|
context,
|
||||||
|
documentsRelativePath(sessionFolder),
|
||||||
|
fileName,
|
||||||
|
jsonString,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Writes or overwrites [jsonString] at public Documents/{relativePath}/{fileName}. */
|
||||||
|
fun writeOrReplacePublicJsonFile(
|
||||||
|
context: Context,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
|
||||||
|
} else {
|
||||||
|
writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
||||||
@@ -126,22 +157,57 @@ object RunDataStorage {
|
|||||||
return array
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeViaMediaStore(
|
private fun writeOrReplaceViaMediaStore(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val resolver = context.applicationContext.contentResolver
|
val resolver = context.applicationContext.contentResolver
|
||||||
val relativePath = documentsRelativePath(runSessionFolder)
|
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
|
val existingUri = findMediaStoreFileUri(resolver, collection, relativePath, fileName)
|
||||||
|
if (existingUri != null) {
|
||||||
|
resolver.openOutputStream(existingUri, "wt")?.use { stream ->
|
||||||
|
stream.write(jsonString.toByteArray(Charsets.UTF_8))
|
||||||
|
} ?: return null
|
||||||
|
return existingUri
|
||||||
|
}
|
||||||
|
return insertViaMediaStore(resolver, collection, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findMediaStoreFileUri(
|
||||||
|
resolver: android.content.ContentResolver,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
): Uri? {
|
||||||
|
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||||
|
val selection =
|
||||||
|
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?"
|
||||||
|
val selectionArgs = arrayOf(relativePathWithTrailingSlash(relativePath), fileName)
|
||||||
|
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||||
|
return ContentUris.withAppendedId(collection, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertViaMediaStore(
|
||||||
|
resolver: android.content.ContentResolver,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? {
|
||||||
val pending =
|
val pending =
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePathWithTrailingSlash(relativePath))
|
||||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
}
|
}
|
||||||
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
||||||
val uri = resolver.insert(collection, pending) ?: return null
|
val uri = resolver.insert(collection, pending) ?: return null
|
||||||
try {
|
try {
|
||||||
resolver.openOutputStream(uri)?.use { stream ->
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
@@ -159,17 +225,23 @@ object RunDataStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MediaStore stores directory relative paths with a trailing separator. */
|
||||||
|
private fun relativePathWithTrailingSlash(relativePath: String): String =
|
||||||
|
if (relativePath.endsWith("/")) relativePath else "$relativePath/"
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun writeViaPublicDocumentsDir(
|
private fun writeOrReplaceViaPublicDocumentsDir(
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val dir =
|
val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
|
||||||
File(
|
val lockstepPrefix = "$APP_DIR/"
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
|
val subPath =
|
||||||
"$APP_DIR/$runSessionFolder",
|
relativePath.removePrefix("${Environment.DIRECTORY_DOCUMENTS}/")
|
||||||
)
|
.removePrefix("${Environment.DIRECTORY_DOCUMENTS}${File.separator}")
|
||||||
|
.removePrefix(lockstepPrefix)
|
||||||
|
val dir = File(documentsRoot, "$APP_DIR/$subPath")
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_bg">#E65100</color>
|
<color name="ic_launcher_bg">#ffffff</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
|
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
|
||||||
|
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_stub_body">More controls will land here in a later milestone.</string>
|
<string name="settings_logout_title">Logout</string>
|
||||||
<string name="settings_logout_spotify">Sign out of Spotify</string>
|
<string name="settings_logout_spotify">Sign out of Spotify</string>
|
||||||
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
|
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
|
||||||
<string name="settings_annotation_mode">Annotation mode</string>
|
<string name="settings_annotation_mode">Annotation mode</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user