4 Commits

23 changed files with 192 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# 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
@@ -23,6 +23,8 @@
## Nice-to
- beat metadata is handled by the app, currently. The handling should move into "jukebox".
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
- TrackFileMatching, scoreAgainstLocalHints

View File

@@ -20,7 +20,7 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LockstepPlayer">
<activity

View File

@@ -192,7 +192,7 @@ class LockstepViewModel(
context = appContext,
sessionFolder = sessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
trackQueueIndex0Based = event.playlistPosition,
contentId = contentId,
title = event.title,
artist = event.artist,
@@ -207,7 +207,7 @@ class LockstepViewModel(
BeatAnnotationStorage.writeBeatsFile(
context = appContext,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
trackQueueIndex0Based = event.playlistPosition,
contentId = contentId,
title = event.title,
artist = event.artist,
@@ -278,7 +278,7 @@ class LockstepViewModel(
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
trackQueueIndex0Based = event.playlistPosition,
trackId = event.trackId,
metaContentUri = meta,
title = event.title,
@@ -296,7 +296,7 @@ class LockstepViewModel(
trackId: String,
title: String,
artist: String,
queueIndex: Int,
playlistPosition: Int,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
@@ -308,7 +308,7 @@ class LockstepViewModel(
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex,
trackQueueIndex0Based = playlistPosition,
trackId = trackId,
metaContentUri = meta,
title = title,
@@ -453,6 +453,22 @@ class LockstepViewModel(
}
}
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
@Volatile
private var suppressNextLibraryMetadataSync = false
fun suppressNextLibraryMetadataSync() {
suppressNextLibraryMetadataSync = true
}
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
if (!suppressNextLibraryMetadataSync) {
return false
}
suppressNextLibraryMetadataSync = false
return true
}
suspend fun syncPendingMetadata(): String? {
val token = spotifyAccessToken.value
if (token.isNullOrBlank()) {

View File

@@ -110,6 +110,7 @@ class PlaybackService : Service() {
title = item.title,
artist = item.artist,
queueIndex = i,
playlistPosition = item.playlistPosition,
queueSize = queue.size,
reason = reason,
),
@@ -265,6 +266,7 @@ class PlaybackService : Service() {
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
durationMsHint = hint,
playlistPosition = row.position,
)
}
index = 0
@@ -300,6 +302,7 @@ class PlaybackService : Service() {
isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentQueueIndex = index,
currentPlaylistPosition = item.playlistPosition,
queueSize = queue.size,
)
updateProgressFromEngine()
@@ -367,6 +370,8 @@ class PlaybackService : Service() {
durationSeconds = durationSec,
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
currentQueueIndex = index,
currentPlaylistPosition =
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
queueSize = queue.size,
)
updatePlaybackStateFromEngine()
@@ -566,6 +571,8 @@ class PlaybackService : Service() {
val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
/** 0-based position in the full Spotify playlist for the current track. */
val currentPlaylistPosition: Int,
val queueSize: Int,
) {
companion object {
@@ -578,6 +585,7 @@ class PlaybackService : Service() {
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0,
)
}
@@ -590,6 +598,8 @@ class PlaybackService : Service() {
val localUri: Uri?,
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
/** 0-based position in the full Spotify playlist. */
val playlistPosition: Int,
)
companion object {

View File

@@ -8,8 +8,10 @@ data class TrackBoundaryEvent(
val trackId: String,
val title: 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,
/** 0-based position in the full Spotify playlist (used for beat/run-data file slots). */
val playlistPosition: Int,
val queueSize: Int,
val reason: TrackBoundaryReason,
)

View File

@@ -91,17 +91,21 @@ fun AnnotationRoute(
var playlistDisplayName by remember { mutableStateOf("playlist") }
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 name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList()
beatTimesMs.clear()
viewModel.persistBeatAnnotation(
playlistId,
playlistDisplayName,
name,
annotationSessionFolder,
event,
snapshot,

View File

@@ -116,7 +116,10 @@ fun LockstepAppNavHost(
playlistId = playlistId,
playback = playback,
viewModel = viewModel,
onBack = { navController.popBackStack() },
onBack = {
viewModel.suppressNextLibraryMetadataSync()
navController.popBackStack()
},
)
}
composable(

View File

@@ -2,6 +2,7 @@ package at.lockstep.player.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -169,6 +170,7 @@ fun NowPlayingRoute(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
BackHandler(onBack = onBack)
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
val collector = remember { RunDataCollector(context) }
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
@@ -194,7 +196,7 @@ fun NowPlayingRoute(
}
var playlistDisplayName by remember { mutableStateOf("playlist") }
var currentTrackId by remember { mutableStateOf<String?>(null) }
var currentQueueIndex by remember { mutableIntStateOf(0) }
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
var ui by remember {
mutableStateOf(
@@ -210,18 +212,16 @@ fun NowPlayingRoute(
}
LaunchedEffect(playlistId) {
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
}
LaunchedEffect(playlistId, playlistDisplayName) {
viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName)
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
}
LaunchedEffect(playback) {
val service = playback ?: return@LaunchedEffect
service.uiState.collect { p ->
currentTrackId = p.currentTrackId
currentQueueIndex = p.currentQueueIndex
currentPlaylistPosition = p.currentPlaylistPosition
ui =
NowPlayingUiState(
title = p.title,
@@ -255,12 +255,14 @@ fun NowPlayingRoute(
}
}
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
LaunchedEffect(collectRunData, playback, playlistId) {
if (!collectRunData) return@LaunchedEffect
val service = playback ?: return@LaunchedEffect
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
service.trackBoundaryEvents.collect { event ->
val snapshot = collector.snapshotAndClear()
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
}
}
@@ -283,7 +285,7 @@ fun NowPlayingRoute(
trackId = trackId,
title = ui.title,
artist = ui.artist,
queueIndex = currentQueueIndex,
playlistPosition = currentPlaylistPosition,
snapshot = snapshot,
)
}

View File

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

66
media/shoe_logo4.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,53 @@
"""Downscale launcher icon PNGs from mipmap-xxxhdpi masters to lower densities."""
from __future__ import annotations
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
RES = ROOT / "app" / "src" / "main" / "res"
SOURCE_FOLDER = "mipmap-xxxhdpi"
SOURCES = {
"ic_launcher.png": {
"mipmap-xxxhdpi": 192,
"mipmap-xxhdpi": 144,
"mipmap-xhdpi": 96,
"mipmap-hdpi": 72,
"mipmap-mdpi": 48,
},
"ic_launcher_foreground.png": {
"mipmap-xxxhdpi": 432,
"mipmap-xxhdpi": 324,
"mipmap-xhdpi": 216,
"mipmap-hdpi": 162,
"mipmap-mdpi": 108,
},
}
def main() -> None:
for filename, sizes in SOURCES.items():
source_path = RES / SOURCE_FOLDER / filename
source = Image.open(source_path)
expected = sizes[SOURCE_FOLDER]
if source.size != (expected, expected):
raise RuntimeError(
f"{source_path} is {source.size[0]}x{source.size[1]}, expected {expected}x{expected}"
)
for folder, size in sizes.items():
if folder == SOURCE_FOLDER:
continue
out_dir = RES / folder
out_dir.mkdir(parents=True, exist_ok=True)
resized = source.resize((size, size), Image.Resampling.LANCZOS)
resized.save(out_dir / filename)
print(f"Wrote {folder}/{filename} ({size}x{size})")
print("Done.")
if __name__ == "__main__":
main()