10 Commits

35 changed files with 617 additions and 54 deletions

2
.gitignore vendored
View File

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

18
BUGS.md
View File

@@ -1,6 +1,20 @@
# 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.
- There is a bug where re-opening the app (closing it through swiping away on the android app screen, then re-opening it) will display a splash screen with the app icon large and centered, and nothing happens.
- repro:
- open "Run 8" playlist
- skip a few songs forward/backward
- swipe away the app in Android
- (LibPasada.stop() - PlaybackEngine::stop() - musicFeed->join() hangs)
- open app again: warm restart by Android
- splash screen shows app icon, hangs there
- MP3File::~MP3File() must call mp3file_delete()
O> (fixed on 2026-06-11) scrubber was returning 2x the time progress from PasadaMusicPlayerEngine / liblockstep
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 +37,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

@@ -81,6 +81,13 @@ class LockstepViewModel(
false,
)
val feedAccelToPasada: StateFlow<Boolean> =
prefs.feedAccelToPasada.stateIn(
viewModelScope,
SharingStarted.Eagerly,
true,
)
fun setAnnotationMode(enabled: Boolean) {
viewModelScope.launch {
prefs.setAnnotationMode(enabled)
@@ -93,6 +100,12 @@ class LockstepViewModel(
}
}
fun setFeedAccelToPasada(enabled: Boolean) {
viewModelScope.launch {
prefs.setFeedAccelToPasada(enabled)
}
}
private val context get() = getApplication<Application>()
/**
@@ -192,7 +205,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 +220,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 +291,7 @@ class LockstepViewModel(
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
trackQueueIndex0Based = event.playlistPosition,
trackId = event.trackId,
metaContentUri = meta,
title = event.title,
@@ -296,7 +309,7 @@ class LockstepViewModel(
trackId: String,
title: String,
artist: String,
queueIndex: Int,
playlistPosition: Int,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
@@ -308,7 +321,7 @@ class LockstepViewModel(
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex,
trackQueueIndex0Based = playlistPosition,
trackId = trackId,
metaContentUri = meta,
title = title,
@@ -453,6 +466,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()) {
@@ -546,13 +575,34 @@ class LockstepViewModel(
app.playlistRepository.syncPlaylistDetail(playlistId)
null
} catch (e: LockstepApiException) {
e.message ?: "Load failed"
syncPlaylistDetailErrorForLibraryOpen(playlistId, e)
} 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() {
viewModelScope.launch {
prefs.setOnboardingComplete(true)

View File

@@ -39,6 +39,12 @@ class UserPreferencesRepository(
prefs[KEY_COLLECT_RUN_DATA] == true
}
/** When true, accelerometer samples are forwarded to libpasada during playback (not while annotating). */
val feedAccelToPasada: Flow<Boolean> =
dataStore.data.map { prefs ->
prefs[KEY_FEED_ACCEL_TO_PASADA] != false
}
suspend fun setOnboardingComplete(done: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_ONBOARDING_COMPLETE] = done
@@ -67,10 +73,17 @@ class UserPreferencesRepository(
}
}
suspend fun setFeedAccelToPasada(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_FEED_ACCEL_TO_PASADA] = enabled
}
}
companion object {
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
private val KEY_FEED_ACCEL_TO_PASADA = booleanPreferencesKey("feed_accel_to_pasada")
}
}

View File

@@ -21,7 +21,7 @@ public final class LibPasada {
if (loaded) {
return;
}
System.loadLibrary("pasada");
System.loadLibrary("lockstep-native");
loaded = true;
}
@@ -37,8 +37,14 @@ public final class LibPasada {
* @param fd open read FD (Java retains ownership; do not close until track changes)
* @param offset start offset within the FD (0 for whole file)
* @param length byte length from offset ({@code -1} if unknown / to EOF)
* @param beatTimesSec beat times in seconds from track start, or {@code null} when unknown
*/
public static native void play(int fd, long offset, long length);
public static native void play(int fd, long offset, long length, double[] beatTimesSec);
/** Same as {@link #play(int, long, long, double[])} with no beat annotation. */
public static void play(int fd, long offset, long length) {
play(fd, offset, length, null);
}
/** PLAYING → PAUSED (silent output, graph kept alive). */
public static native void pause();
@@ -72,4 +78,7 @@ public final class LibPasada {
/** Register listener for async events raised from the audio/native thread. */
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}. */
void onTrackFinished();
void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message);
}

View File

@@ -0,0 +1,77 @@
package at.lockstep.player.playback
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import at.lockstep.player.pasada.LibPasada
/**
* Registers the accelerometer on a background [HandlerThread] and forwards samples to
* [LibPasada.feedAccel]. Owned by [PlaybackService] for the duration of a run session.
*/
class PasadaAccelFeeder(
context: Context,
) {
private val sensorManager = context.applicationContext.getSystemService(SensorManager::class.java)
private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private val handlerThread = HandlerThread("PasadaAccel").apply { start() }
private val handler = Handler(handlerThread.looper)
@Volatile
private var feedingEnabled = false
private var registered = false
private val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (!feedingEnabled || event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
return
}
LibPasada.feedAccel(
event.values[0],
event.values[1],
event.values[2],
event.timestamp,
)
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
fun start() {
if (registered || sensorManager == null || accelerometer == null) {
return
}
feedingEnabled = true
sensorManager.registerListener(
listener,
accelerometer,
SensorManager.SENSOR_DELAY_GAME,
0,
handler,
)
registered = true
}
fun stop() {
feedingEnabled = false
if (!registered || sensorManager == null) {
return
}
sensorManager.unregisterListener(listener)
registered = false
}
fun release() {
stop()
handlerThread.quitSafely()
}
}

View File

@@ -16,8 +16,11 @@ import androidx.media.app.NotificationCompat.MediaStyle
import at.lockstep.player.LockstepApplication
import at.lockstep.player.MainActivity
import at.lockstep.player.R
import at.lockstep.player.data.db.FileMetadataEntity
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.util.BeatAnnotationStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -47,9 +50,18 @@ class PlaybackService : Service() {
private val binder = LocalBinder()
private var engine: MusicPlayerEngine? = null
private var accelFeeder: PasadaAccelFeeder? = null
private var positionPollJob: Job? = null
private var positionCachePollJob: Job? = null
/** User preference: forward accelerometer samples to libpasada. */
@Volatile
private var feedAccelEnabled = true
/** True while the beat annotation screen is visible. */
@Volatile
private var isAnnotating = false
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
@@ -110,6 +122,7 @@ class PlaybackService : Service() {
title = item.title,
artist = item.artist,
queueIndex = i,
playlistPosition = item.playlistPosition,
queueSize = queue.size,
reason = reason,
),
@@ -203,15 +216,50 @@ class PlaybackService : Service() {
engine?.let {
return it
}
return ExoPlayerMusicPlayerEngine(this)
//return ExoPlayerMusicPlayerEngine(this)
return PasadaMusicPlayerEngine(this)
.also {
it.setListener(engineListener)
it.initSession()
engine = it
refreshAccelFeeding()
startPositionPolling()
}
}
/** Called when the user toggles "Adapt playback to movement" in Settings. */
fun setFeedAccelEnabled(enabled: Boolean) {
feedAccelEnabled = enabled
refreshAccelFeeding()
}
/** Called when entering or leaving the beat annotation screen. */
fun setAnnotating(annotating: Boolean) {
isAnnotating = annotating
refreshAccelFeeding()
}
private fun refreshAccelFeeding() {
if (engine == null) {
return
}
if (feedAccelEnabled && !isAnnotating) {
startAccelFeeding()
} else {
stopAccelFeeding()
}
}
private fun startAccelFeeding() {
val feeder =
accelFeeder ?: PasadaAccelFeeder(this).also { accelFeeder = it }
feeder.start()
}
private fun stopAccelFeeding() {
accelFeeder?.stop()
}
private fun startPositionPolling() {
positionPollJob?.cancel()
positionPollJob =
@@ -238,6 +286,7 @@ class PlaybackService : Service() {
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
stopAccelFeeding()
engine?.setListener(null)
engine?.releaseSession()
engine = null
@@ -265,6 +314,7 @@ class PlaybackService : Service() {
artist = row.artistName ?: "",
localUri = Uri.parse(uriStr),
durationMsHint = hint,
playlistPosition = row.position,
)
}
index = 0
@@ -287,31 +337,59 @@ class PlaybackService : Service() {
private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item)
scope.launch {
val beatTimesSec =
withContext(Dispatchers.IO) {
loadBeatTimesSecForTrack(item.id)
}
withContext(Dispatchers.Main) {
val current = queue.getOrNull(index) ?: return@withContext
if (current.id != item.id) {
return@withContext
}
applyCurrentMediaItem(current, beatTimesSec)
val durationSec =
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1)
?: (current.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value =
PlaybackUiState(
title = item.title,
artist = item.artist,
title = current.title,
artist = current.artist,
progress = 0f,
durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentTrackId = current.id,
currentQueueIndex = index,
currentPlaylistPosition = current.playlistPosition,
queueSize = queue.size,
)
updateProgressFromEngine()
updateSessionMetadata(item, durationSec)
updateSessionMetadata(current, durationSec)
updatePlaybackStateFromEngine()
refreshForegroundNotification()
}
}
}
private fun applyCurrentMediaItem(item: TrackQueueItem) {
private suspend fun loadBeatTimesSecForTrack(trackId: String): DoubleArray? {
val metadata =
app.database.fileMetadataDao().findByTrackIdAndType(
trackId,
FileMetadataEntity.TYPE_BEATS,
) ?: return null
if (metadata.fileUri.isBlank()) {
return null
}
return BeatAnnotationStorage.readBeatTimesSec(app, metadata.fileUri)
}
private fun applyCurrentMediaItem(
item: TrackQueueItem,
beatTimesSec: DoubleArray?,
) {
val uri = item.localUri ?: return
val e = ensureEngine()
e.prepareTrack(uri)
e.prepareTrack(uri, beatTimesSec)
if (_uiState.value.isPlaying) {
e.play()
} else {
@@ -367,6 +445,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()
@@ -533,6 +613,8 @@ class PlaybackService : Service() {
override fun onDestroy() {
stopPlaybackAndTeardown()
accelFeeder?.release()
accelFeeder = null
super.onDestroy()
}
@@ -566,6 +648,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 +662,7 @@ class PlaybackService : Service() {
isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
currentPlaylistPosition = 0,
queueSize = 0,
)
}
@@ -590,6 +675,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

@@ -33,7 +33,10 @@ class ExoPlayerMusicPlayerEngine(
.also { it.addListener(playerListener) }
}
override fun prepareTrack(uri: Uri) {
override fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray?,
) {
val p = requirePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()

View File

@@ -14,7 +14,13 @@ interface MusicPlayerEngine {
* Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is
* called (or immediately if [playWhenReady] is set via [play]).
*/
fun prepareTrack(uri: Uri)
/**
* @param beatTimesSec optional beat times in seconds from [TYPE_BEATS] metadata; passed to libpasada on play.
*/
fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray? = null,
)
fun play()

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException
@@ -26,6 +27,7 @@ class PasadaMusicPlayerEngine(
private var trackFd: Int? = null
private var trackOffset: Long = 0L
private var trackLength: Long = -1L
private var pendingBeatTimesSec: DoubleArray? = null
/** FD is open but [LibPasada.play] has not been called yet for this track. */
private var pendingStart = false
@@ -35,12 +37,18 @@ class PasadaMusicPlayerEngine(
private val nativeListener =
object : PasadaPlaybackListener {
override fun onTrackFinished() {
Log.i(TAG, "PasadaPlaybackListener.onTrackFinished()")
pendingStart = false
mainHandler.post {
listener?.onPlaybackEnded()
}
}
override fun onTrackClosed(fd: Int) {
Log.i(TAG, "onTrackClosed: native returned fd=$fd")
// we handle parcel close() separately
}
override fun onError(
errorCode: Int,
message: String,
@@ -51,20 +59,29 @@ class PasadaMusicPlayerEngine(
}
}
val TAG = "PasadaMusicPlayerEngine"
override fun initSession() {
if (sessionInitialized) {
return
}
Log.i(TAG, "LibPasada.loadNative() ...")
LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener)
Log.i(TAG, "LibPasada.init() ...")
LibPasada.init()
Log.i(TAG, "LibPasada.init() done.")
sessionInitialized = true
}
override fun prepareTrack(uri: Uri) {
override fun prepareTrack(
uri: Uri,
beatTimesSec: DoubleArray?,
) {
closeOpenTrack()
try {
openTrack(uri)
pendingBeatTimesSec = beatTimesSec
pendingStart = true
} catch (e: IOException) {
closeOpenTrack()
@@ -119,8 +136,11 @@ class PasadaMusicPlayerEngine(
override fun releaseSession() {
closeOpenTrack()
if (sessionInitialized) {
LibPasada.setPlaybackListener(null)
Log.i(TAG, "LibPasada.stop() ...")
LibPasada.stop()
Log.i(TAG, "LibPasada.stop() done.")
LibPasada.setPlaybackListener(null)
Log.i(TAG, "LibPasada.setPlaybackListener(null) done.")
sessionInitialized = false
}
}
@@ -131,7 +151,14 @@ class PasadaMusicPlayerEngine(
private fun startPendingTrack() {
val fd = trackFd ?: return
LibPasada.play(fd, trackOffset, trackLength)
val beats = pendingBeatTimesSec
Log.i(
TAG,
"LibPasada.play(fd=$fd, offset=$trackOffset, length=$trackLength, beats=${beats?.size ?: 0}) ...",
)
LibPasada.play(fd, trackOffset, trackLength, beats)
Log.i(TAG, "LibPasada.play() done.")
pendingBeatTimesSec = null
pendingStart = false
}
@@ -165,6 +192,7 @@ class PasadaMusicPlayerEngine(
trackFd = null
trackOffset = 0L
trackLength = -1L
pendingBeatTimesSec = null
pendingStart = false
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -85,23 +86,34 @@ fun AnnotationRoute(
}
}
DisposableEffect(playback) {
playback?.setAnnotating(true)
onDispose {
playback?.setAnnotating(false)
}
}
val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
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

@@ -8,6 +8,7 @@ import android.os.IBinder
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,8 +35,13 @@ fun LockstepAppNavHost(
viewModel: LockstepViewModel,
) {
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle()
var playback by remember { mutableStateOf<PlaybackService?>(null) }
LaunchedEffect(feedAccelToPasada, playback) {
playback?.setFeedAccelEnabled(feedAccelToPasada)
}
DisposableEffect(Unit) {
val connection =
object : ServiceConnection {
@@ -116,7 +122,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,
@@ -234,6 +234,11 @@ fun NowPlayingRoute(
}
}
DisposableEffect(playback) {
playback?.setAnnotating(false)
onDispose { }
}
LaunchedEffect(collectRunData, playback) {
if (!collectRunData) {
collector.setCollectingEnabled(false)
@@ -255,12 +260,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 +290,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

@@ -38,6 +38,7 @@ fun SettingsScreen(
val context = LocalContext.current
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
val feedAccelToPasada by viewModel.feedAccelToPasada.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -83,6 +84,30 @@ fun SettingsScreen(
onCheckedChange = { viewModel.setAnnotationMode(it) },
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(
text = context.getString(R.string.settings_feed_accel_to_pasada),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_feed_accel_to_pasada_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = feedAccelToPasada,
onCheckedChange = { viewModel.setFeedAccelToPasada(it) },
)
}
Row(
modifier =
Modifier

View File

@@ -62,6 +62,25 @@ object BeatAnnotationStorage {
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
}
/** Reads [beatTimesSec] from a beats or annotation JSON file; {@code null} if missing or unreadable. */
fun readBeatTimesSec(
context: Context,
fileUri: String,
): DoubleArray? {
if (fileUri.isBlank()) {
return null
}
return try {
context.contentResolver.openInputStream(Uri.parse(fileUri))?.use { stream ->
val json = JSONObject(stream.bufferedReader().readText())
val arr = json.optJSONArray("beatTimesSec") ?: return null
DoubleArray(arr.length()) { i -> arr.getDouble(i) }
}
} catch (_: Exception) {
null
}
}
fun buildAnnotationJson(
contentId: 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

@@ -32,6 +32,8 @@
<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_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="settings_feed_accel_to_pasada">Adapt playback to movement</string>
<string name="settings_feed_accel_to_pasada_help">When enabled, accelerometer data is sent to libpasada during Now playing so tempo can follow your pace. Disabled automatically on the beat annotation screen.</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_tap_area_label">Tap here on the beat</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()