8 Commits

34 changed files with 483 additions and 32 deletions

2
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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()) {
@@ -442,13 +562,34 @@ class LockstepViewModel(
app.playlistRepository.syncPlaylistDetail(playlistId) app.playlistRepository.syncPlaylistDetail(playlistId)
null null
} catch (e: LockstepApiException) { } catch (e: LockstepApiException) {
e.message ?: "Load failed" syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} catch (e: IOException) { } catch (e: IOException) {
e.message ?: "Load failed" syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} }
} }
} }
/**
* Playlist detail fetch is best-effort when opening from the library. Unauthorized responses mean
* the token is stale; cached jukebox rows (if any) are still valid for pairing and playback.
*/
private fun syncPlaylistDetailErrorForLibraryOpen(playlistId: String, e: Exception): String? {
if (isPlaylistDetailHttp401(e)) {
Log.w(TAG, "syncPlaylistDetail unauthorized for $playlistId, opening from jukebox cache", e)
return null
}
if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) {
Log.w(TAG, "syncPlaylistDetail failed for $playlistId, using cached tracks", e)
return null
}
return e.message ?: "Load failed"
}
private fun isPlaylistDetailHttp401(e: Exception): Boolean {
val msg = e.message ?: return false
return msg.contains("HTTP 401", ignoreCase = true)
}
fun completeOnboarding() { fun completeOnboarding() {
viewModelScope.launch { viewModelScope.launch {
prefs.setOnboardingComplete(true) prefs.setOnboardingComplete(true)

View File

@@ -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,

View File

@@ -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 :

View File

@@ -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)
} }

View File

@@ -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"

View File

@@ -21,7 +21,7 @@ public final class LibPasada {
if (loaded) { if (loaded) {
return; return;
} }
System.loadLibrary("pasada"); System.loadLibrary("lockstep-native");
loaded = true; loaded = true;
} }
@@ -72,4 +72,7 @@ public final class LibPasada {
/** Register listener for async events raised from the audio/native thread. */ /** Register listener for async events raised from the audio/native thread. */
public static native void setPlaybackListener(PasadaPlaybackListener listener); public static native void setPlaybackListener(PasadaPlaybackListener listener);
/** native version string */
public static native String getVersion();
} }

View File

@@ -9,6 +9,8 @@ public interface PasadaPlaybackListener {
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ /** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished(); void onTrackFinished();
void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ /** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message); void onError(int errorCode, String message);
} }

View File

@@ -17,6 +17,7 @@ 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.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.PasadaMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine import at.lockstep.player.playback.engine.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -110,6 +111,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,
), ),
@@ -203,7 +205,8 @@ class PlaybackService : Service() {
engine?.let { engine?.let {
return it return it
} }
return ExoPlayerMusicPlayerEngine(this) //return ExoPlayerMusicPlayerEngine(this)
return PasadaMusicPlayerEngine(this)
.also { .also {
it.setListener(engineListener) it.setListener(engineListener)
it.initSession() it.initSession()
@@ -265,6 +268,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 +304,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 +372,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 +573,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 +587,7 @@ class PlaybackService : Service() {
isPlaying = false, isPlaying = false,
currentTrackId = null, currentTrackId = null,
currentQueueIndex = 0, currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0, queueSize = 0,
) )
} }
@@ -590,6 +600,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 {

View File

@@ -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,
) )

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log
import at.lockstep.player.pasada.LibPasada import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException import java.io.IOException
@@ -35,12 +36,18 @@ class PasadaMusicPlayerEngine(
private val nativeListener = private val nativeListener =
object : PasadaPlaybackListener { object : PasadaPlaybackListener {
override fun onTrackFinished() { override fun onTrackFinished() {
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
pendingStart = false pendingStart = false
mainHandler.post { mainHandler.post {
listener?.onPlaybackEnded() listener?.onPlaybackEnded()
} }
} }
override fun onTrackClosed(fd: Int) {
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
// we handle parcel close() separately
}
override fun onError( override fun onError(
errorCode: Int, errorCode: Int,
message: String, message: String,
@@ -51,13 +58,18 @@ class PasadaMusicPlayerEngine(
} }
} }
val TAG = "PasadaMusicPlayerEngine"
override fun initSession() { override fun initSession() {
if (sessionInitialized) { if (sessionInitialized) {
return return
} }
Log.i(TAG, "LibPasada.loadNative() ...")
LibPasada.loadNative() LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener) LibPasada.setPlaybackListener(nativeListener)
Log.i(TAG, "LibPasada.init() ...")
LibPasada.init() LibPasada.init()
Log.i(TAG, "LibPasada.init() done.")
sessionInitialized = true sessionInitialized = true
} }
@@ -131,7 +143,9 @@ class PasadaMusicPlayerEngine(
private fun startPendingTrack() { private fun startPendingTrack() {
val fd = trackFd ?: return val fd = trackFd ?: return
Log.i(TAG, "LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength) ...")
LibPasada.play(fd, trackOffset, trackLength) LibPasada.play(fd, trackOffset, trackLength)
Log.i(TAG, "LibPasada.play() done.")
pendingStart = false pendingStart = false
} }

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,
) )
} }

View File

@@ -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()

View File

@@ -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,

View File

@@ -0,0 +1,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.rex.logger;
import android.util.Log;
//import proguard.annotation.KeepName;
//@KeepName
/**
* Gather native log into logback, for easy control save log into SDCard
*/
public class NdkLogger {
private static final String TAG = "NDK";
//@KeepName
public static void logWrite(int level, String message) {
//sLogger.trace("level:{} message:{}", level, message);
switch (level) {
case Log.VERBOSE:
Log.v(TAG, message);
break;
case Log.DEBUG:
Log.d(TAG, message);
break;
case Log.INFO:
Log.i(TAG, message);
break;
case Log.WARN:
Log.w(TAG, message);
break;
case Log.ERROR:
Log.e(TAG, message);
break;
}
}
static {
try {
System.loadLibrary("ndk-logger");
} catch (UnsatisfiedLinkError ex) {
Log.e(TAG, "Failed to load library.", ex);
}
}
public static String getABI() { return native_getABI(); }
private static native String native_getABI();
}

View File

@@ -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

View File

@@ -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

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()