Compare commits
6 Commits
12adfbc960
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 922cc5d82a | |||
| 88db4ad18d | |||
| a10102c10a | |||
| 19bbccf244 | |||
| f7c5b932e9 | |||
| 633afd115d |
4
BUGS.md
@@ -1,6 +1,6 @@
|
|||||||
# Bugs
|
# Bugs
|
||||||
|
|
||||||
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||||
|
|
||||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
|
|
||||||
## Nice-to
|
## Nice-to
|
||||||
|
|
||||||
|
- beat metadata is handled by the app, currently. The handling should move into "jukebox".
|
||||||
|
|
||||||
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
||||||
|
|
||||||
- TrackFileMatching, scoreAgainstLocalHints
|
- TrackFileMatching, scoreAgainstLocalHints
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LockstepPlayer">
|
android:theme="@style/Theme.LockstepPlayer">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.lockstep.jukebox.api.LockstepApiException
|
import at.lockstep.jukebox.api.LockstepApiException
|
||||||
import at.lockstep.jukebox.db.TrackRow
|
import at.lockstep.jukebox.db.TrackRow
|
||||||
|
import at.lockstep.player.data.MetadataFetchResult
|
||||||
import at.lockstep.player.data.UserPreferencesRepository
|
import at.lockstep.player.data.UserPreferencesRepository
|
||||||
import at.lockstep.player.data.db.FileMetadataEntity
|
import at.lockstep.player.data.db.FileMetadataEntity
|
||||||
import at.lockstep.player.data.db.TrackPairingEntity
|
import at.lockstep.player.data.db.TrackPairingEntity
|
||||||
@@ -191,7 +192,7 @@ class LockstepViewModel(
|
|||||||
context = appContext,
|
context = appContext,
|
||||||
sessionFolder = sessionFolder,
|
sessionFolder = sessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
@@ -206,7 +207,7 @@ class LockstepViewModel(
|
|||||||
BeatAnnotationStorage.writeBeatsFile(
|
BeatAnnotationStorage.writeBeatsFile(
|
||||||
context = appContext,
|
context = appContext,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
@@ -277,7 +278,7 @@ class LockstepViewModel(
|
|||||||
writeRunDataAndRecordMetadata(
|
writeRunDataAndRecordMetadata(
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.playlistPosition,
|
||||||
trackId = event.trackId,
|
trackId = event.trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
@@ -295,7 +296,7 @@ class LockstepViewModel(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
queueIndex: Int,
|
playlistPosition: Int,
|
||||||
snapshot: RunTrackDataSnapshot,
|
snapshot: RunTrackDataSnapshot,
|
||||||
) {
|
) {
|
||||||
if (snapshot.isEmpty()) {
|
if (snapshot.isEmpty()) {
|
||||||
@@ -307,7 +308,7 @@ class LockstepViewModel(
|
|||||||
writeRunDataAndRecordMetadata(
|
writeRunDataAndRecordMetadata(
|
||||||
runSessionFolder = runSessionFolder,
|
runSessionFolder = runSessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = queueIndex,
|
trackQueueIndex0Based = playlistPosition,
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
metaContentUri = meta,
|
metaContentUri = meta,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -349,6 +350,125 @@ class LockstepViewModel(
|
|||||||
syncMetadataEntry(entry.copy(id = id))
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches beat metadata from the server for each track in [playlistId] that has no local beats file.
|
||||||
|
* Records [FileMetadataEntity.lastFetchAttemptAt] when the server returns 404 so polling can retry later.
|
||||||
|
*/
|
||||||
|
fun fetchBeatsMetadataForPlaylist(
|
||||||
|
playlistId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
|
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||||
|
for (track in tracks) {
|
||||||
|
val trackId = track.trackId ?: continue
|
||||||
|
fetchBeatMetadataForTrack(
|
||||||
|
token = token,
|
||||||
|
trackId = trackId,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
queueIndex = track.position,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchBeatMetadataForTrack(
|
||||||
|
token: String,
|
||||||
|
trackId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
queueIndex: Int,
|
||||||
|
) {
|
||||||
|
val existing =
|
||||||
|
fileMetadataDao.findByTrackIdAndType(trackId, FileMetadataEntity.TYPE_BEATS)
|
||||||
|
if (existing != null && existing.fileUri.isNotBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (existing?.lastFetchAttemptAt != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val attemptAt = System.currentTimeMillis()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
app.metadataSyncClient.fetchMetadata(
|
||||||
|
accessToken = token,
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(METADATA_TAG, "beat metadata fetch failed trackId=$trackId", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is MetadataFetchResult.Found -> {
|
||||||
|
val beatsUri =
|
||||||
|
BeatAnnotationStorage.writeBeatsFileFromJson(
|
||||||
|
context = getApplication(),
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = queueIndex,
|
||||||
|
json = result.collection,
|
||||||
|
) ?: return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.updateFile(existing.id, beatsUri.toString(), BuildConfig.VERSION_CODE, now)
|
||||||
|
} else {
|
||||||
|
fileMetadataDao.insert(
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = beatsUri.toString(),
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = true,
|
||||||
|
lastSyncedAt = now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(METADATA_TAG, "fetched beat metadata from server trackId=$trackId")
|
||||||
|
}
|
||||||
|
MetadataFetchResult.NotFound -> {
|
||||||
|
recordBeatMetadataUnavailable(trackId, attemptAt, existing)
|
||||||
|
Log.d(METADATA_TAG, "beat metadata not on server trackId=$trackId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recordBeatMetadataUnavailable(
|
||||||
|
trackId: String,
|
||||||
|
attemptAt: Long,
|
||||||
|
existing: FileMetadataEntity?,
|
||||||
|
) {
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.recordFetchAttempt(existing.id, attemptAt)
|
||||||
|
} else {
|
||||||
|
fileMetadataDao.insert(
|
||||||
|
FileMetadataEntity(
|
||||||
|
fileUri = "",
|
||||||
|
trackId = trackId,
|
||||||
|
type = FileMetadataEntity.TYPE_BEATS,
|
||||||
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = false,
|
||||||
|
lastFetchAttemptAt = attemptAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
|
||||||
|
@Volatile
|
||||||
|
private var suppressNextLibraryMetadataSync = false
|
||||||
|
|
||||||
|
fun suppressNextLibraryMetadataSync() {
|
||||||
|
suppressNextLibraryMetadataSync = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
|
||||||
|
if (!suppressNextLibraryMetadataSync) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suppressNextLibraryMetadataSync = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncPendingMetadata(): String? {
|
suspend fun syncPendingMetadata(): String? {
|
||||||
val token = spotifyAccessToken.value
|
val token = spotifyAccessToken.value
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.data
|
package at.lockstep.player.data
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -7,10 +8,56 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
|
sealed class MetadataFetchResult {
|
||||||
|
data class Found(val collection: JSONObject) : MetadataFetchResult()
|
||||||
|
|
||||||
|
data object NotFound : MetadataFetchResult()
|
||||||
|
}
|
||||||
|
|
||||||
class MetadataSyncClient(
|
class MetadataSyncClient(
|
||||||
private val httpClient: OkHttpClient,
|
private val httpClient: OkHttpClient,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
) {
|
) {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun fetchMetadata(
|
||||||
|
accessToken: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
): MetadataFetchResult {
|
||||||
|
val url =
|
||||||
|
baseUrl.toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addPathSegments("metadata")
|
||||||
|
.addQueryParameter("trackId", trackId)
|
||||||
|
.addQueryParameter("type", type)
|
||||||
|
.build()
|
||||||
|
val request =
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 404) {
|
||||||
|
return MetadataFetchResult.NotFound
|
||||||
|
}
|
||||||
|
val bodyText = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val message =
|
||||||
|
runCatching { JSONObject(bodyText).optString("error") }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: response.message
|
||||||
|
throw IOException(message)
|
||||||
|
}
|
||||||
|
val collection =
|
||||||
|
runCatching { JSONObject(bodyText).getJSONObject("collection") }
|
||||||
|
.getOrNull()
|
||||||
|
?: throw IOException("Missing collection in response")
|
||||||
|
return MetadataFetchResult.Found(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun uploadCollection(
|
fun uploadCollection(
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
|||||||
TrackPairingEntity::class,
|
TrackPairingEntity::class,
|
||||||
FileMetadataEntity::class,
|
FileMetadataEntity::class,
|
||||||
],
|
],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase :
|
abstract class AppDatabase :
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ interface FileMetadataDao {
|
|||||||
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
|
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
|
||||||
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
|
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
|
||||||
|
|
||||||
|
@Query("UPDATE file_metadata SET lastFetchAttemptAt = :attemptAt WHERE id = :id")
|
||||||
|
suspend fun recordFetchAttempt(id: Long, attemptAt: Long)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt WHERE id = :id",
|
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt, lastFetchAttemptAt = NULL WHERE id = :id",
|
||||||
)
|
)
|
||||||
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class FileMetadataEntity(
|
|||||||
val synced: Boolean = false,
|
val synced: Boolean = false,
|
||||||
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
|
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
|
||||||
val lastSyncedAt: Long? = null,
|
val lastSyncedAt: Long? = null,
|
||||||
|
/** Epoch millis of the last server fetch attempt when beat metadata was unavailable (404). */
|
||||||
|
val lastFetchAttemptAt: Long? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val TYPE_COLLECTION = "collection"
|
const val TYPE_COLLECTION = "collection"
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class PlaybackService : Service() {
|
|||||||
title = item.title,
|
title = item.title,
|
||||||
artist = item.artist,
|
artist = item.artist,
|
||||||
queueIndex = i,
|
queueIndex = i,
|
||||||
|
playlistPosition = item.playlistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
reason = reason,
|
reason = reason,
|
||||||
),
|
),
|
||||||
@@ -265,6 +266,7 @@ class PlaybackService : Service() {
|
|||||||
artist = row.artistName ?: "—",
|
artist = row.artistName ?: "—",
|
||||||
localUri = Uri.parse(uriStr),
|
localUri = Uri.parse(uriStr),
|
||||||
durationMsHint = hint,
|
durationMsHint = hint,
|
||||||
|
playlistPosition = row.position,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
index = 0
|
index = 0
|
||||||
@@ -300,6 +302,7 @@ class PlaybackService : Service() {
|
|||||||
isPlaying = _uiState.value.isPlaying,
|
isPlaying = _uiState.value.isPlaying,
|
||||||
currentTrackId = item.id,
|
currentTrackId = item.id,
|
||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
|
currentPlaylistPosition = item.playlistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updateProgressFromEngine()
|
updateProgressFromEngine()
|
||||||
@@ -367,6 +370,8 @@ class PlaybackService : Service() {
|
|||||||
durationSeconds = durationSec,
|
durationSeconds = durationSec,
|
||||||
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
||||||
currentQueueIndex = index,
|
currentQueueIndex = index,
|
||||||
|
currentPlaylistPosition =
|
||||||
|
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
|
||||||
queueSize = queue.size,
|
queueSize = queue.size,
|
||||||
)
|
)
|
||||||
updatePlaybackStateFromEngine()
|
updatePlaybackStateFromEngine()
|
||||||
@@ -566,6 +571,8 @@ class PlaybackService : Service() {
|
|||||||
val isPlaying: Boolean,
|
val isPlaying: Boolean,
|
||||||
val currentTrackId: String?,
|
val currentTrackId: String?,
|
||||||
val currentQueueIndex: Int,
|
val currentQueueIndex: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist for the current track. */
|
||||||
|
val currentPlaylistPosition: Int,
|
||||||
val queueSize: Int,
|
val queueSize: Int,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -578,6 +585,7 @@ class PlaybackService : Service() {
|
|||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
currentTrackId = null,
|
currentTrackId = null,
|
||||||
currentQueueIndex = 0,
|
currentQueueIndex = 0,
|
||||||
|
currentPlaylistPosition = 0,
|
||||||
queueSize = 0,
|
queueSize = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -590,6 +598,8 @@ class PlaybackService : Service() {
|
|||||||
val localUri: Uri?,
|
val localUri: Uri?,
|
||||||
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||||
val durationMsHint: Int,
|
val durationMsHint: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist. */
|
||||||
|
val playlistPosition: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ data class TrackBoundaryEvent(
|
|||||||
val trackId: String,
|
val trackId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String,
|
val artist: String,
|
||||||
/** 0-based index in the current play queue when this track was current. */
|
/** 0-based index in the paired-only play queue when this track was current. */
|
||||||
val queueIndex: Int,
|
val queueIndex: Int,
|
||||||
|
/** 0-based position in the full Spotify playlist (used for beat/run-data file slots). */
|
||||||
|
val playlistPosition: Int,
|
||||||
val queueSize: Int,
|
val queueSize: Int,
|
||||||
val reason: TrackBoundaryReason,
|
val reason: TrackBoundaryReason,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,17 +91,21 @@ fun AnnotationRoute(
|
|||||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
|
|
||||||
LaunchedEffect(playlistId) {
|
LaunchedEffect(playlistId) {
|
||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
|
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
|
LaunchedEffect(playback, playlistId, annotationSessionFolder) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
service.trackBoundaryEvents.collect { event ->
|
service.trackBoundaryEvents.collect { event ->
|
||||||
val snapshot = beatTimesMs.toList()
|
val snapshot = beatTimesMs.toList()
|
||||||
beatTimesMs.clear()
|
beatTimesMs.clear()
|
||||||
viewModel.persistBeatAnnotation(
|
viewModel.persistBeatAnnotation(
|
||||||
playlistId,
|
playlistId,
|
||||||
playlistDisplayName,
|
name,
|
||||||
annotationSessionFolder,
|
annotationSessionFolder,
|
||||||
event,
|
event,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ fun LockstepAppNavHost(
|
|||||||
playlistId = playlistId,
|
playlistId = playlistId,
|
||||||
playback = playback,
|
playback = playback,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = {
|
||||||
|
viewModel.suppressNextLibraryMetadataSync()
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package at.lockstep.player.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -169,6 +170,7 @@ fun NowPlayingRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||||
val collector = remember { RunDataCollector(context) }
|
val collector = remember { RunDataCollector(context) }
|
||||||
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||||
@@ -194,7 +196,7 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
||||||
var currentQueueIndex by remember { mutableIntStateOf(0) }
|
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
var ui by remember {
|
var ui by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -210,14 +212,16 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playlistId) {
|
LaunchedEffect(playlistId) {
|
||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
|
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback) {
|
LaunchedEffect(playback) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
service.uiState.collect { p ->
|
service.uiState.collect { p ->
|
||||||
currentTrackId = p.currentTrackId
|
currentTrackId = p.currentTrackId
|
||||||
currentQueueIndex = p.currentQueueIndex
|
currentPlaylistPosition = p.currentPlaylistPosition
|
||||||
ui =
|
ui =
|
||||||
NowPlayingUiState(
|
NowPlayingUiState(
|
||||||
title = p.title,
|
title = p.title,
|
||||||
@@ -251,12 +255,14 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
|
LaunchedEffect(collectRunData, playback, playlistId) {
|
||||||
if (!collectRunData) return@LaunchedEffect
|
if (!collectRunData) return@LaunchedEffect
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
playlistDisplayName = name
|
||||||
service.trackBoundaryEvents.collect { event ->
|
service.trackBoundaryEvents.collect { event ->
|
||||||
val snapshot = collector.snapshotAndClear()
|
val snapshot = collector.snapshotAndClear()
|
||||||
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
|
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +285,7 @@ fun NowPlayingRoute(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
title = ui.title,
|
title = ui.title,
|
||||||
artist = ui.artist,
|
artist = ui.artist,
|
||||||
queueIndex = currentQueueIndex,
|
playlistPosition = currentPlaylistPosition,
|
||||||
snapshot = snapshot,
|
snapshot = snapshot,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ fun LibraryScreen(
|
|||||||
|
|
||||||
LaunchedEffect(token) {
|
LaunchedEffect(token) {
|
||||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||||
|
val skipMetadataSync = viewModel.consumeSuppressNextLibraryMetadataSync()
|
||||||
val errors =
|
val errors =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
viewModel.syncJukeboxIfToken(),
|
viewModel.syncJukeboxIfToken(),
|
||||||
viewModel.syncPendingMetadata(),
|
if (skipMetadataSync) null else viewModel.syncPendingMetadata(),
|
||||||
)
|
)
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ object BeatAnnotationStorage {
|
|||||||
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes server-fetched beat JSON under Documents/Lockstep/Beats/{playlist_name}/. */
|
||||||
|
fun writeBeatsFileFromJson(
|
||||||
|
context: Context,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
json: JSONObject,
|
||||||
|
): Uri? {
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
val fileName = "$suffix.json"
|
||||||
|
val jsonString = json.toString(2)
|
||||||
|
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
|
||||||
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
fun buildAnnotationJson(
|
fun buildAnnotationJson(
|
||||||
contentId: String,
|
contentId: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -1,21 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Lockstep</string>
|
<string name="app_name">Pasada</string>
|
||||||
<string name="cd_previous_track">Previous track</string>
|
<string name="cd_previous_track">Previous track</string>
|
||||||
<string name="cd_play_pause">Play or pause</string>
|
<string name="cd_play_pause">Play or pause</string>
|
||||||
<string name="cd_next_track">Next track</string>
|
<string name="cd_next_track">Next track</string>
|
||||||
|
|
||||||
<string name="notification_channel_playback_name">Playback</string>
|
<string name="notification_channel_playback_name">Playback</string>
|
||||||
<string name="notification_channel_playback_description">Now playing controls while Lockstep runs</string>
|
<string name="notification_channel_playback_description">Now playing controls while Pasada runs</string>
|
||||||
<string name="notification_prev">Previous</string>
|
<string name="notification_prev">Previous</string>
|
||||||
<string name="notification_next">Next</string>
|
<string name="notification_next">Next</string>
|
||||||
<string name="notification_play_pause">Play or pause</string>
|
<string name="notification_play_pause">Play or pause</string>
|
||||||
<string name="notification_loading_playlist">Loading playlist…</string>
|
<string name="notification_loading_playlist">Loading playlist…</string>
|
||||||
|
|
||||||
<string name="onboarding_title">Welcome to Lockstep</string>
|
<string name="onboarding_title">Welcome to Pasada</string>
|
||||||
<string name="onboarding_notifications_body">Lockstep shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
<string name="onboarding_notifications_body">Pasada shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
||||||
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
|
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
|
||||||
<string name="onboarding_spotify_body">Sign in with Spotify via the Lockstep web login. When your browser returns to this app, your access token is stored locally.</string>
|
<string name="onboarding_spotify_body">Sign in with Spotify via the Pasada web login. When your browser returns to this app, your access token is stored locally.</string>
|
||||||
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
|
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
|
||||||
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
|
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
|
||||||
<string name="onboarding_continue_signed_in">Continue</string>
|
<string name="onboarding_continue_signed_in">Continue</string>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<string name="settings_annotation_mode">Annotation mode</string>
|
<string name="settings_annotation_mode">Annotation mode</string>
|
||||||
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
|
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
|
||||||
<string name="settings_collect_run_data">Collect run data</string>
|
<string name="settings_collect_run_data">Collect run data</string>
|
||||||
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Lockstep/ under a timestamped run folder.</string>
|
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder.</string>
|
||||||
<string name="annotation_title">Beat annotation</string>
|
<string name="annotation_title">Beat annotation</string>
|
||||||
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
|
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
|
||||||
<string name="annotation_tap_area_label">Tap here on the beat</string>
|
<string name="annotation_tap_area_label">Tap here on the beat</string>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
||||||
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
||||||
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
|
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
|
||||||
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See LockstepPairing logs.</string>
|
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See PasadaPairing logs.</string>
|
||||||
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
|
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
|
||||||
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
|
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
|
||||||
<string name="pairing_unknown_track">(removed or unknown track)</string>
|
<string name="pairing_unknown_track">(removed or unknown track)</string>
|
||||||
|
|||||||
66
media/shoe_logo4.svg
Normal file
|
After Width: | Height: | Size: 51 KiB |
53
scripts/generate_launcher_icons.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Downscale launcher icon PNGs from mipmap-xxxhdpi masters to lower densities."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RES = ROOT / "app" / "src" / "main" / "res"
|
||||||
|
SOURCE_FOLDER = "mipmap-xxxhdpi"
|
||||||
|
|
||||||
|
SOURCES = {
|
||||||
|
"ic_launcher.png": {
|
||||||
|
"mipmap-xxxhdpi": 192,
|
||||||
|
"mipmap-xxhdpi": 144,
|
||||||
|
"mipmap-xhdpi": 96,
|
||||||
|
"mipmap-hdpi": 72,
|
||||||
|
"mipmap-mdpi": 48,
|
||||||
|
},
|
||||||
|
"ic_launcher_foreground.png": {
|
||||||
|
"mipmap-xxxhdpi": 432,
|
||||||
|
"mipmap-xxhdpi": 324,
|
||||||
|
"mipmap-xhdpi": 216,
|
||||||
|
"mipmap-hdpi": 162,
|
||||||
|
"mipmap-mdpi": 108,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
for filename, sizes in SOURCES.items():
|
||||||
|
source_path = RES / SOURCE_FOLDER / filename
|
||||||
|
source = Image.open(source_path)
|
||||||
|
expected = sizes[SOURCE_FOLDER]
|
||||||
|
if source.size != (expected, expected):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{source_path} is {source.size[0]}x{source.size[1]}, expected {expected}x{expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for folder, size in sizes.items():
|
||||||
|
if folder == SOURCE_FOLDER:
|
||||||
|
continue
|
||||||
|
out_dir = RES / folder
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
resized = source.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(out_dir / filename)
|
||||||
|
print(f"Wrote {folder}/{filename} ({size}x{size})")
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||