Compare commits
6 Commits
feature/en
...
306ad83d12
| Author | SHA1 | Date | |
|---|---|---|---|
| 306ad83d12 | |||
| 9d161bd2a9 | |||
| ad2dc18f00 | |||
| 8d1b762b78 | |||
| e936dee142 | |||
| 45c4980c05 |
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
## 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/).
|
||||||
|
|||||||
@@ -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,13 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described
|
* Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
|
||||||
* by [event]. Skips when [beatTimesMs] is empty.
|
* by [event], records file metadata, and uploads when signed in. 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 +184,26 @@ class LockstepViewModel(
|
|||||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||||
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
||||||
val contentId = docId.ifBlank { event.trackId }
|
val contentId = docId.ifBlank { event.trackId }
|
||||||
|
val uri =
|
||||||
BeatAnnotationStorage.writeAnnotationsFile(
|
BeatAnnotationStorage.writeAnnotationsFile(
|
||||||
context = getApplication(),
|
context = getApplication(),
|
||||||
|
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
|
||||||
|
val entry =
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = uri.toString(),
|
||||||
|
trackId = event.trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_ANNOTATION,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
)
|
)
|
||||||
|
val id = fileMetadataDao.insert(entry)
|
||||||
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +224,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 +254,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 +267,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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = 4,
|
||||||
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,17 @@
|
|||||||
|
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 ORDER BY id ASC")
|
||||||
|
suspend fun listUnsynced(): List<FileMetadataEntity>
|
||||||
|
|
||||||
|
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
|
||||||
|
suspend fun markSynced(id: Long)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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] or [TYPE_ANNOTATION]. */
|
||||||
|
val type: String,
|
||||||
|
/** [BuildConfig.VERSION_CODE] at write time. */
|
||||||
|
val version: Int,
|
||||||
|
val synced: Boolean = false,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TYPE_COLLECTION = "collection"
|
||||||
|
const val TYPE_ANNOTATION = "annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private var queue: List<TrackQueueItem> = emptyList()
|
private var queue: List<TrackQueueItem> = emptyList()
|
||||||
private var index: Int = 0
|
private var index: Int = 0
|
||||||
|
private var tornDown = false
|
||||||
|
|
||||||
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -524,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()
|
||||||
|
positionCachePollJob = null
|
||||||
releaseEngine()
|
releaseEngine()
|
||||||
|
queue = emptyList()
|
||||||
|
index = 0
|
||||||
|
cachedPlaybackPositionMs = 0L
|
||||||
|
_uiState.value = PlaybackUiState.initial()
|
||||||
|
if (::mediaSession.isInitialized) {
|
||||||
mediaSession.run {
|
mediaSession.run {
|
||||||
isActive = false
|
isActive = false
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
super.onDestroy()
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlaybackUiState(
|
data class PlaybackUiState(
|
||||||
|
|||||||
@@ -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,28 +5,24 @@ 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,
|
context: Context,
|
||||||
|
sessionFolder: String,
|
||||||
playlistDisplayName: String,
|
playlistDisplayName: String,
|
||||||
trackQueueIndex0Based: Int,
|
trackQueueIndex0Based: Int,
|
||||||
contentId: String,
|
contentId: String,
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
beatTimesMs: List<Long>,
|
beatTimesMs: List<Long>,
|
||||||
): File {
|
): Uri? {
|
||||||
val safeName =
|
val safeName =
|
||||||
playlistDisplayName
|
playlistDisplayName
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
@@ -35,17 +31,16 @@ object BeatAnnotationStorage {
|
|||||||
.take(120)
|
.take(120)
|
||||||
val suffix =
|
val suffix =
|
||||||
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
val file = File(annotationsDir(context), "${safeName}_$suffix.json")
|
val fileName = "${safeName}_$suffix.json"
|
||||||
val sec = beatTimesMs.map { it / 1000.0 }
|
val sec = beatTimesMs.map { it / 1000.0 }
|
||||||
val json =
|
val jsonString =
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("contentId", contentId)
|
put("contentId", contentId)
|
||||||
put("title", title)
|
put("title", title)
|
||||||
put("artist", artist)
|
put("artist", artist)
|
||||||
put("beatTimesSec", JSONArray(sec))
|
put("beatTimesSec", JSONArray(sec))
|
||||||
}
|
}.toString(2)
|
||||||
file.writeText(json.toString(2))
|
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
||||||
return file
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
||||||
|
|||||||
@@ -58,11 +58,20 @@ 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? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
writeViaMediaStore(context, sessionFolder, fileName, jsonString)
|
||||||
|
} else {
|
||||||
|
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
||||||
|
|||||||
@@ -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