9 Commits

33 changed files with 861 additions and 174 deletions

View File

@@ -2,6 +2,15 @@
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**. Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
## UI screens
- Onboarding: explains why notifications are necessary, implements login
- Library: shows the playlists of the user
- Annotation: user presses a button to annotate the beat
- Now Playing: player showing an individual track, with pause/previous/next buttons
- Collection mode: app records sensor data during a run
- Settings: allows to enable modes above, logout button
## Already captured from SPECS.md ## Already captured from SPECS.md
- **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback. - **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback.

View File

@@ -11,6 +11,8 @@ Android prototype: music playback adapts to running pace (accelerometer + native
**App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/). **App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/).
**API:** [`lockstep-2-api/`](lockstep-2-api/) — `api.py` contains the Python API that is deployed on the server behind api.lockstep.at
Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studios **jbr** under `/Applications/…` or `/c/Program Files/…`. Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studios **jbr** under `/Applications/…` or `/c/Program Files/…`.
Submodule: [`jukebox/`](jukebox/). Submodule: [`jukebox/`](jukebox/).

View File

@@ -18,9 +18,9 @@
<application <application
android:name=".LockstepApplication" android:name=".LockstepApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher_layer" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_layer" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LockstepPlayer"> android:theme="@style/Theme.LockstepPlayer">
<activity <activity

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.os.Build import android.os.Build
import at.lockstep.jukebox.Jukebox import at.lockstep.jukebox.Jukebox
import at.lockstep.jukebox.PlaylistRepository import at.lockstep.jukebox.PlaylistRepository
import at.lockstep.player.data.MetadataSyncClient
import at.lockstep.player.data.db.AppDatabase import at.lockstep.player.data.db.AppDatabase
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class LockstepApplication : Application() { class LockstepApplication : Application() {
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.getInstance(this) } val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
val metadataSyncClient: MetadataSyncClient by lazy {
MetadataSyncClient(
OkHttpClient(),
BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/'),
)
}
val playlistRepository: PlaylistRepository by lazy { val playlistRepository: PlaylistRepository by lazy {
Jukebox.playlistRepository( Jukebox.playlistRepository(
this, this,

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import at.lockstep.jukebox.api.LockstepApiException import at.lockstep.jukebox.api.LockstepApiException
import at.lockstep.jukebox.db.TrackRow import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.data.UserPreferencesRepository import at.lockstep.player.data.UserPreferencesRepository
import at.lockstep.player.data.db.FileMetadataEntity
import at.lockstep.player.data.db.TrackPairingEntity import at.lockstep.player.data.db.TrackPairingEntity
import at.lockstep.player.util.AudioUriValidator import at.lockstep.player.util.AudioUriValidator
import at.lockstep.player.playback.TrackBoundaryEvent import at.lockstep.player.playback.TrackBoundaryEvent
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -38,6 +41,7 @@ class LockstepViewModel(
companion object { companion object {
private const val TAG = "LockstepPairing" private const val TAG = "LockstepPairing"
private const val METADATA_TAG = "LockstepMetadata"
/** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */ /** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>() private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
@@ -46,6 +50,7 @@ class LockstepViewModel(
} }
private val prefs = UserPreferencesRepository(application) private val prefs = UserPreferencesRepository(application)
private val pairingDao get() = app.database.pairingDao() private val pairingDao get() = app.database.pairingDao()
private val fileMetadataDao get() = app.database.fileMetadataDao()
val onboardingComplete: StateFlow<Boolean> = val onboardingComplete: StateFlow<Boolean> =
prefs.onboardingComplete.stateIn( prefs.onboardingComplete.stateIn(
@@ -162,12 +167,13 @@ class LockstepViewModel(
} }
/** /**
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described * Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
* by [event]. Skips when [beatTimesMs] is empty. * by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty.
*/ */
fun persistBeatAnnotation( fun persistBeatAnnotation(
playlistId: String, playlistId: String,
playlistDisplayName: String, playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent, event: TrackBoundaryEvent,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
) { ) {
@@ -178,15 +184,26 @@ class LockstepViewModel(
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
val contentId = docId.ifBlank { event.trackId } val contentId = docId.ifBlank { event.trackId }
BeatAnnotationStorage.writeAnnotationsFile( val uri =
context = getApplication(), BeatAnnotationStorage.writeAnnotationsFile(
playlistDisplayName = playlistDisplayName, context = getApplication(),
trackQueueIndex0Based = event.queueIndex, sessionFolder = sessionFolder,
contentId = contentId, playlistDisplayName = playlistDisplayName,
title = event.title, trackQueueIndex0Based = event.queueIndex,
artist = event.artist, contentId = contentId,
beatTimesMs = beatTimesMs, title = event.title,
) artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
val entry =
FileMetadataEntity(
fileUri = uri.toString(),
trackId = event.trackId,
type = FileMetadataEntity.TYPE_ANNOTATION,
version = BuildConfig.VERSION_CODE,
)
val id = fileMetadataDao.insert(entry)
syncMetadataEntry(entry.copy(id = id))
} }
} }
@@ -207,11 +224,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex, trackQueueIndex0Based = event.queueIndex,
trackId = event.trackId,
metaContentUri = meta, metaContentUri = meta,
title = event.title, title = event.title,
artist = event.artist, artist = event.artist,
@@ -237,11 +254,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId) val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex, trackQueueIndex0Based = queueIndex,
trackId = trackId,
metaContentUri = meta, metaContentUri = meta,
title = title, title = title,
artist = artist, artist = artist,
@@ -250,6 +267,99 @@ class LockstepViewModel(
} }
} }
private suspend fun writeRunDataAndRecordMetadata(
runSessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
trackId: String,
metaContentUri: String,
title: String,
artist: String,
snapshot: RunTrackDataSnapshot,
) {
val uri =
RunDataStorage.writeRunDataFile(
context = getApplication(),
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = trackQueueIndex0Based,
metaContentUri = metaContentUri,
title = title,
artist = artist,
snapshot = snapshot,
) ?: return
val entry =
FileMetadataEntity(
fileUri = uri.toString(),
trackId = trackId,
type = FileMetadataEntity.TYPE_COLLECTION,
version = BuildConfig.VERSION_CODE,
)
val id = fileMetadataDao.insert(entry)
syncMetadataEntry(entry.copy(id = id))
}
suspend fun syncPendingMetadata(): String? {
val token = spotifyAccessToken.value
if (token.isNullOrBlank()) {
return null
}
return withContext(Dispatchers.IO) {
val pending = fileMetadataDao.listUnsynced()
if (pending.isEmpty()) {
return@withContext null
}
val failures = pending.count { entry -> !syncMetadataEntry(entry) }
if (failures > 0) {
"Failed to sync $failures file(s)"
} else {
null
}
}
}
private suspend fun syncMetadataEntry(entry: FileMetadataEntity): Boolean {
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return false
val collection =
withContext(Dispatchers.IO) {
readCollectionJson(entry.fileUri)
} ?: return false
return withContext(Dispatchers.IO) {
try {
app.metadataSyncClient.uploadCollection(
accessToken = token,
trackId = entry.trackId,
type = entry.type,
version = entry.version,
collection = collection,
)
fileMetadataDao.markSynced(entry.id)
true
} catch (e: IOException) {
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
false
}
}
}
private fun readCollectionJson(fileUri: String): JSONObject? {
val uri = Uri.parse(fileUri)
try {
context.contentResolver.openInputStream(uri)?.use { stream ->
return JSONObject(stream.bufferedReader().readText())
}
} catch (e: Exception) {
Log.w(METADATA_TAG, "readCollectionJson contentResolver failed uri=$fileUri", e)
}
return try {
val path = uri.path ?: return null
JSONObject(File(path).readText())
} catch (e: Exception) {
Log.w(METADATA_TAG, "readCollectionJson file path failed uri=$fileUri", e)
null
}
}
suspend fun syncJukeboxIfToken(): String? { suspend fun syncJukeboxIfToken(): String? {
val token = spotifyAccessToken.value val token = spotifyAccessToken.value
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {

View File

@@ -0,0 +1,53 @@
package at.lockstep.player.data
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class MetadataSyncClient(
private val httpClient: OkHttpClient,
private val baseUrl: String,
) {
@Throws(IOException::class)
fun uploadCollection(
accessToken: String,
trackId: String,
type: String,
version: Int,
collection: JSONObject,
) {
val payload =
JSONObject()
.apply {
put("trackId", trackId)
put("type", type)
put("version", version)
put("collection", collection)
}.toString()
val request =
Request.Builder()
.url("$baseUrl/metadata")
.addHeader("Authorization", "Bearer $accessToken")
.post(payload.toRequestBody(JSON_MEDIA))
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return
}
val bodyText = response.body?.string().orEmpty()
val message =
runCatching { JSONObject(bodyText).optString("error") }
.getOrNull()
?.takeIf { it.isNotBlank() }
?: response.message
throw IOException(message)
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}
}

View File

@@ -6,14 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [TrackPairingEntity::class], entities = [
version = 2, TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 4,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : abstract class AppDatabase :
RoomDatabase() { RoomDatabase() {
abstract fun pairingDao(): PairingDao abstract fun pairingDao(): PairingDao
abstract fun fileMetadataDao(): FileMetadataDao
companion object { companion object {
private const val DB_NAME = "lockstep.db" private const val DB_NAME = "lockstep.db"

View File

@@ -0,0 +1,17 @@
package at.lockstep.player.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface FileMetadataDao {
@Insert
suspend fun insert(row: FileMetadataEntity): Long
@Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC")
suspend fun listUnsynced(): List<FileMetadataEntity>
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
suspend fun markSynced(id: Long)
}

View File

@@ -0,0 +1,24 @@
package at.lockstep.player.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "file_metadata")
data class FileMetadataEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** Content or file URI of the persisted JSON. */
val fileUri: String,
/** Spotify track id for the collected run. */
val trackId: String,
/** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */
val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */
val version: Int,
val synced: Boolean = false,
) {
companion object {
const val TYPE_COLLECTION = "collection"
const val TYPE_ANNOTATION = "annotation"
}
}

View File

@@ -0,0 +1,75 @@
package at.lockstep.player.pasada;
/**
* JNI entry point for libpasada. Used by {@link at.lockstep.player.playback.engine.PasadaMusicPlayerEngine};
* {@link at.lockstep.player.playback.PlaybackService} still defaults to
* {@link at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine} until the native library is ready.
*
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*
* <p>Call {@link #loadNative()} once before any other method once the {@code pasada} shared
* library is added via CMake (step 3).
*/
public final class LibPasada {
private static boolean loaded;
private LibPasada() {}
/** Loads {@code libpasada.so}. Safe to call multiple times. */
public static synchronized void loadNative() {
if (loaded) {
return;
}
System.loadLibrary("pasada");
loaded = true;
}
/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */
public static native void init();
/** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */
public static native void feedAccel(float x, float y, float z, long timestampNanos);
/**
* Open MP3 from an already-open file descriptor and begin adaptive playback.
*
* @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)
*/
public static native void play(int fd, long offset, long length);
/** PLAYING → PAUSED (silent output, graph kept alive). */
public static native void pause();
/** PAUSED → PLAYING (same track, same decode position, same FD). */
public static native void resume();
/** Tear down Oboe for this run segment → STOPPED. */
public static native void stop();
/**
* Milliseconds from the start of the current track — same timebase as ExoPlayer
* {@code getCurrentPosition()}. Safe to poll from background threads.
*/
public static native long getCurrentPositionMs();
/** Track duration in ms, or {@code 0} if not yet known. */
public static native long getDurationMs();
/** Whether adapted audio is actively being output (not paused, not finished). */
public static native boolean isPlaying();
/** Current native state; see {@link PasadaState}. */
public static native int getState();
/** Seek within the current track. */
public static native void seekTo(long positionMs);
/** Runtime metrics / last error string for logging and debug UI. */
public static native String getDiagnostics();
/** Register listener for async events raised from the audio/native thread. */
public static native void setPlaybackListener(PasadaPlaybackListener listener);
}

View File

@@ -0,0 +1,14 @@
package at.lockstep.player.pasada;
/**
* Callbacks invoked from native (Oboe or internal worker thread).
* Implementations must post to the main thread if they touch UI or service state.
*/
public interface PasadaPlaybackListener {
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished();
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message);
}

View File

@@ -0,0 +1,26 @@
package at.lockstep.player.pasada;
/** Mirrors the libpasada state machine documented in DESIGN.md. */
public enum PasadaState {
LOADED(0),
INITIALIZED(1),
PLAYING(2),
PAUSED(3),
FINISHED(4),
STOPPED(5);
public final int code;
PasadaState(int code) {
this.code = code;
}
public static PasadaState fromCode(int code) {
for (PasadaState state : values()) {
if (state.code == code) {
return state;
}
}
throw new IllegalArgumentException("Unknown PasadaState code: " + code);
}
}

View File

@@ -13,12 +13,11 @@ import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import at.lockstep.player.LockstepApplication 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.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -36,10 +35,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use * Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is * beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* the same timeline used for seeking and end-of-track reporting — see Media3 * current media item).
* [Player] / [ExoPlayer] documentation.
*/ */
class PlaybackService : Service() { class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -48,7 +46,7 @@ class PlaybackService : Service() {
private val binder = LocalBinder() private val binder = LocalBinder()
private var player: ExoPlayer? = null private var engine: MusicPlayerEngine? = null
private var positionPollJob: Job? = null private var positionPollJob: Job? = null
private var positionCachePollJob: Job? = null private var positionCachePollJob: Job? = null
@@ -64,33 +62,39 @@ class PlaybackService : Service() {
private var queue: List<TrackQueueItem> = emptyList() private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0 private var index: Int = 0
private var tornDown = false
/** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */ /** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
@Volatile @Volatile
private var cachedPlaybackPositionMs: Long = 0L private var cachedPlaybackPositionMs: Long = 0L
private val playerListener = private val engineListener =
object : Player.Listener { object : MusicPlayerEngine.Listener {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackEnded() {
if (playbackState == Player.STATE_ENDED) { if (queue.isEmpty()) {
if (queue.isEmpty()) { return
return
}
if (index < queue.lastIndex) {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
)
index++
publishCurrentTrack()
} else {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.LAST_TRACK_FINISHED,
)
setPlaying(false)
}
} }
if (index < queue.lastIndex) {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
)
index++
publishCurrentTrack()
} else {
emitTrackBoundaryForQueueIndex(
index,
TrackBoundaryReason.LAST_TRACK_FINISHED,
)
setPlaying(false)
}
}
override fun onError(
errorCode: Int,
message: String,
) {
setPlaying(false)
} }
} }
@@ -142,9 +146,8 @@ class PlaybackService : Service() {
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
val p = player ?: return engine?.seekTo(pos)
p.seekTo(pos) updateProgressFromEngine()
updateProgressFromPlayer()
} }
}, },
) )
@@ -196,15 +199,15 @@ class PlaybackService : Service() {
} }
} }
private fun ensurePlayer(): ExoPlayer { private fun ensureEngine(): MusicPlayerEngine {
player?.let { engine?.let {
return it return it
} }
return ExoPlayer.Builder(this) return ExoPlayerMusicPlayerEngine(this)
.build()
.also { .also {
it.addListener(playerListener) it.setListener(engineListener)
player = it it.initSession()
engine = it
startPositionPolling() startPositionPolling()
} }
} }
@@ -215,8 +218,8 @@ class PlaybackService : Service() {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
delay(UPDATE_INTERVAL_MS) delay(UPDATE_INTERVAL_MS)
if (player != null && queue.isNotEmpty()) { if (engine != null && queue.isNotEmpty()) {
updateProgressFromPlayer() updateProgressFromEngine()
} }
} }
} }
@@ -230,14 +233,14 @@ class PlaybackService : Service() {
} }
} }
private fun releasePlayer() { private fun releaseEngine() {
positionPollJob?.cancel() positionPollJob?.cancel()
positionPollJob = null positionPollJob = null
positionCachePollJob?.cancel() positionCachePollJob?.cancel()
positionCachePollJob = null positionCachePollJob = null
player?.removeListener(playerListener) engine?.setListener(null)
player?.release() engine?.releaseSession()
player = null engine = null
} }
private fun startPlaylist(pid: String) { private fun startPlaylist(pid: String) {
@@ -267,14 +270,14 @@ class PlaybackService : Service() {
index = 0 index = 0
if (queue.isEmpty()) { if (queue.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
releasePlayer() releaseEngine()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
return@launch return@launch
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
ensurePlayer() ensureEngine()
setPlaying(true) setPlaying(true)
publishCurrentTrack() publishCurrentTrack()
refreshForegroundNotification() refreshForegroundNotification()
@@ -286,7 +289,7 @@ class PlaybackService : Service() {
val item = queue.getOrNull(index) ?: return val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item) applyCurrentMediaItem(item)
val durationSec = val durationSec =
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt()) (engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1) ?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value = _uiState.value =
PlaybackUiState( PlaybackUiState(
@@ -299,18 +302,21 @@ class PlaybackService : Service() {
currentQueueIndex = index, currentQueueIndex = index,
queueSize = queue.size, queueSize = queue.size,
) )
updateProgressFromPlayer() updateProgressFromEngine()
updateSessionMetadata(item, durationSec) updateSessionMetadata(item, durationSec)
updatePlaybackStateFromPlayer() updatePlaybackStateFromEngine()
refreshForegroundNotification() refreshForegroundNotification()
} }
private fun applyCurrentMediaItem(item: TrackQueueItem) { private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return val uri = item.localUri ?: return
val p = ensurePlayer() val e = ensureEngine()
p.setMediaItem(MediaItem.fromUri(uri)) e.prepareTrack(uri)
p.prepare() if (_uiState.value.isPlaying) {
p.playWhenReady = _uiState.value.isPlaying e.play()
} else {
e.pause()
}
} }
private fun updateSessionMetadata( private fun updateSessionMetadata(
@@ -327,27 +333,26 @@ class PlaybackService : Service() {
} }
private fun currentDurationMs(): Long { private fun currentDurationMs(): Long {
val p = player val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
val fromPlayer = p?.duration?.takeIf { it > 0 } if (fromEngine != null) {
if (fromPlayer != null) { return fromEngine
return fromPlayer
} }
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong() val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint return hint
} }
private fun refreshCachedPlaybackPositionMs() { private fun refreshCachedPlaybackPositionMs() {
val p = player ?: return val e = engine ?: return
if (queue.isEmpty()) { if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L cachedPlaybackPositionMs = 0L
return return
} }
val durationMs = currentDurationMs().coerceAtLeast(1L) val durationMs = currentDurationMs().coerceAtLeast(1L)
cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs) cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
} }
private fun updateProgressFromPlayer() { private fun updateProgressFromEngine() {
val p = player ?: return val e = engine ?: return
refreshCachedPlaybackPositionMs() refreshCachedPlaybackPositionMs()
if (queue.isEmpty()) { if (queue.isEmpty()) {
return return
@@ -364,12 +369,12 @@ class PlaybackService : Service() {
currentQueueIndex = index, currentQueueIndex = index,
queueSize = queue.size, queueSize = queue.size,
) )
updatePlaybackStateFromPlayer() updatePlaybackStateFromEngine()
} }
private fun updatePlaybackStateFromPlayer() { private fun updatePlaybackStateFromEngine() {
val p = player val e = engine
val positionMs = p?.currentPosition ?: 0L val positionMs = e?.getCurrentPositionMs() ?: 0L
val actions = val actions =
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
@@ -377,7 +382,7 @@ class PlaybackService : Service() {
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO PlaybackStateCompat.ACTION_SEEK_TO
val state = val state =
if (_uiState.value.isPlaying && p?.isPlaying == true) { if (_uiState.value.isPlaying && e?.isPlaying() == true) {
PlaybackStateCompat.STATE_PLAYING PlaybackStateCompat.STATE_PLAYING
} else { } else {
PlaybackStateCompat.STATE_PAUSED PlaybackStateCompat.STATE_PAUSED
@@ -392,8 +397,12 @@ class PlaybackService : Service() {
private fun setPlaying(playing: Boolean) { private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing) _uiState.value = _uiState.value.copy(isPlaying = playing)
player?.playWhenReady = playing if (playing) {
updatePlaybackStateFromPlayer() engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
refreshForegroundNotification() refreshForegroundNotification()
} }
@@ -430,15 +439,15 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) { fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return if (queue.isEmpty()) return
val p = player ?: return val e = engine ?: return
val durationMs = currentDurationMs() val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong() val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
p.seekTo(positionMs) e.seekTo(positionMs)
updateProgressFromPlayer() updateProgressFromEngine()
} }
/** /**
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition]. * Milliseconds from the start of the current track — same timebase as the playback engine.
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe. * Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
*/ */
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
@@ -516,17 +525,39 @@ class PlaybackService : Service() {
.build() .build()
} }
override fun onTaskRemoved(rootIntent: Intent?) {
stopPlaybackAndTeardown()
stopSelf()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() { override fun onDestroy() {
positionPollJob?.cancel() stopPlaybackAndTeardown()
positionCachePollJob?.cancel()
releasePlayer()
mediaSession.run {
isActive = false
release()
}
super.onDestroy() super.onDestroy()
} }
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
private fun stopPlaybackAndTeardown() {
if (tornDown) return
tornDown = true
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
releaseEngine()
queue = emptyList()
index = 0
cachedPlaybackPositionMs = 0L
_uiState.value = PlaybackUiState.initial()
if (::mediaSession.isInitialized) {
mediaSession.run {
isActive = false
release()
}
}
stopForeground(STOP_FOREGROUND_REMOVE)
}
data class PlaybackUiState( data class PlaybackUiState(
val title: String, val title: String,
val artist: String, val artist: String,
@@ -557,7 +588,7 @@ class PlaybackService : Service() {
val title: String, val title: String,
val artist: String, val artist: String,
val localUri: Uri?, val localUri: Uri?,
/** Fallback when [ExoPlayer] 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,
) )

View File

@@ -0,0 +1,72 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
/** [MusicPlayerEngine] backed by Media3 [ExoPlayer] until libpasada is wired in. */
class ExoPlayerMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private var player: ExoPlayer? = null
private var listener: MusicPlayerEngine.Listener? = null
private val playerListener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
listener?.onPlaybackEnded()
}
}
}
override fun initSession() {
if (player != null) {
return
}
player =
ExoPlayer.Builder(appContext)
.build()
.also { it.addListener(playerListener) }
}
override fun prepareTrack(uri: Uri) {
val p = requirePlayer()
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
}
override fun play() {
requirePlayer().playWhenReady = true
}
override fun pause() {
player?.playWhenReady = false
}
override fun seekTo(positionMs: Long) {
requirePlayer().seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long = player?.currentPosition ?: 0L
override fun getDurationMs(): Long = player?.duration?.takeIf { it > 0 } ?: 0L
override fun isPlaying(): Boolean = player?.isPlaying == true
override fun releaseSession() {
player?.removeListener(playerListener)
player?.release()
player = null
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun requirePlayer(): ExoPlayer =
player ?: throw IllegalStateException("Call initSession() before using the player")
}

View File

@@ -0,0 +1,44 @@
package at.lockstep.player.playback.engine
import android.net.Uri
/**
* Playback engine used by [at.lockstep.player.playback.PlaybackService].
* ExoPlayer today; libpasada via JNI later.
*/
interface MusicPlayerEngine {
/** Arm the engine for a new playlist/run session. */
fun initSession()
/**
* 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)
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun getCurrentPositionMs(): Long
fun getDurationMs(): Long
fun isPlaying(): Boolean
/** Release resources for this session. */
fun releaseSession()
fun setListener(listener: Listener?)
interface Listener {
fun onPlaybackEnded()
fun onError(
errorCode: Int,
message: String,
)
}
}

View File

@@ -0,0 +1,174 @@
package at.lockstep.player.playback.engine
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import at.lockstep.player.pasada.LibPasada
import at.lockstep.player.pasada.PasadaPlaybackListener
import java.io.IOException
/**
* [MusicPlayerEngine] backed by libpasada JNI. Opens local URIs into file descriptors for native
* decode; maps prepare/play/pause semantics onto [LibPasada.play] / [LibPasada.resume].
*/
class PasadaMusicPlayerEngine(
context: Context,
) : MusicPlayerEngine {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private var listener: MusicPlayerEngine.Listener? = null
private var openAsset: AssetFileDescriptor? = null
private var openParcel: ParcelFileDescriptor? = null
private var trackFd: Int? = null
private var trackOffset: Long = 0L
private var trackLength: Long = -1L
/** FD is open but [LibPasada.play] has not been called yet for this track. */
private var pendingStart = false
private var sessionInitialized = false
private val nativeListener =
object : PasadaPlaybackListener {
override fun onTrackFinished() {
pendingStart = false
mainHandler.post {
listener?.onPlaybackEnded()
}
}
override fun onError(
errorCode: Int,
message: String,
) {
mainHandler.post {
listener?.onError(errorCode, message)
}
}
}
override fun initSession() {
if (sessionInitialized) {
return
}
LibPasada.loadNative()
LibPasada.setPlaybackListener(nativeListener)
LibPasada.init()
sessionInitialized = true
}
override fun prepareTrack(uri: Uri) {
closeOpenTrack()
try {
openTrack(uri)
pendingStart = true
} catch (e: IOException) {
closeOpenTrack()
listener?.onError(ERROR_OPEN_TRACK, e.message ?: "Failed to open $uri")
}
}
override fun play() {
if (pendingStart) {
startPendingTrack()
return
}
if (!sessionInitialized) {
return
}
if (!LibPasada.isPlaying()) {
LibPasada.resume()
}
}
override fun pause() {
if (pendingStart) {
return
}
LibPasada.pause()
}
override fun seekTo(positionMs: Long) {
if (pendingStart) {
return
}
LibPasada.seekTo(positionMs)
}
override fun getCurrentPositionMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getCurrentPositionMs()
}
override fun getDurationMs(): Long =
if (pendingStart || !sessionInitialized) {
0L
} else {
LibPasada.getDurationMs()
}
override fun isPlaying(): Boolean =
sessionInitialized && !pendingStart && LibPasada.isPlaying()
override fun releaseSession() {
closeOpenTrack()
if (sessionInitialized) {
LibPasada.setPlaybackListener(null)
LibPasada.stop()
sessionInitialized = false
}
}
override fun setListener(listener: MusicPlayerEngine.Listener?) {
this.listener = listener
}
private fun startPendingTrack() {
val fd = trackFd ?: return
LibPasada.play(fd, trackOffset, trackLength)
pendingStart = false
}
@Throws(IOException::class)
private fun openTrack(uri: Uri) {
val resolver = appContext.contentResolver
resolver.openAssetFileDescriptor(uri, "r")?.let { afd ->
val pfd =
afd.parcelFileDescriptor
?: throw IOException("No ParcelFileDescriptor for $uri")
openAsset = afd
trackFd = pfd.fd
trackOffset = afd.startOffset
trackLength = if (afd.length >= 0) afd.length else -1L
return
}
val pfd =
resolver.openFileDescriptor(uri, "r")
?: throw IOException("Cannot open $uri")
openParcel = pfd
trackFd = pfd.fd
trackOffset = 0L
trackLength = -1L
}
private fun closeOpenTrack() {
openAsset?.close()
openAsset = null
openParcel?.close()
openParcel = null
trackFd = null
trackOffset = 0L
trackLength = -1L
pendingStart = false
}
private companion object {
const val ERROR_OPEN_TRACK = 1
}
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -32,11 +33,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.lockstep.player.LockstepViewModel import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R import at.lockstep.player.R
import at.lockstep.player.playback.PlaybackService import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.util.RunDataStorage
/** /**
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player] * Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
@@ -53,6 +56,7 @@ fun AnnotationRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val beatListHeight = LocalConfiguration.current.screenHeightDp.dp / 5
var ui by remember { var ui by remember {
mutableStateOf( mutableStateOf(
NowPlayingUiState( NowPlayingUiState(
@@ -82,6 +86,7 @@ fun AnnotationRoute(
} }
val beatTimesMs = remember { mutableStateListOf<Long>() } val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
var playlistDisplayName by remember { mutableStateOf("playlist") } var playlistDisplayName by remember { mutableStateOf("playlist") }
@@ -89,12 +94,18 @@ fun AnnotationRoute(
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
} }
LaunchedEffect(playback, playlistId, playlistDisplayName) { LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
val service = playback ?: return@LaunchedEffect val service = playback ?: return@LaunchedEffect
service.trackBoundaryEvents.collect { event -> service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList() val snapshot = beatTimesMs.toList()
beatTimesMs.clear() beatTimesMs.clear()
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot) viewModel.persistBeatAnnotation(
playlistId,
playlistDisplayName,
annotationSessionFolder,
event,
snapshot,
)
} }
} }
@@ -174,7 +185,7 @@ fun AnnotationRoute(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 160.dp) .height(beatListHeight)
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {

View File

@@ -49,9 +49,13 @@ fun LibraryScreen(
LaunchedEffect(token) { LaunchedEffect(token) {
if (token.isNullOrBlank()) return@LaunchedEffect if (token.isNullOrBlank()) return@LaunchedEffect
val err = viewModel.syncJukeboxIfToken() val errors =
if (err != null) { listOfNotNull(
Toast.makeText(context, err, Toast.LENGTH_LONG).show() viewModel.syncJukeboxIfToken(),
viewModel.syncPendingMetadata(),
)
if (errors.isNotEmpty()) {
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
} }
} }

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -50,6 +51,7 @@ fun OnboardingScreen(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {

View File

@@ -107,24 +107,32 @@ fun SettingsScreen(
onCheckedChange = { viewModel.setCollectRunData(it) }, onCheckedChange = { viewModel.setCollectRunData(it) },
) )
} }
Text( Column(
text = context.getString(R.string.settings_stub_body), modifier =
style = MaterialTheme.typography.bodyLarge, Modifier
) .fillMaxWidth()
Text( .padding(vertical = 8.dp),
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) { ) {
Text(text = context.getString(R.string.settings_logout_spotify)) Text(
text = context.getString(R.string.settings_logout_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
modifier = Modifier.padding(top = 12.dp),
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(text = context.getString(R.string.settings_logout_spotify))
}
} }
} }
} }

View File

@@ -5,28 +5,24 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.util.Locale import java.util.Locale
object BeatAnnotationStorage { object BeatAnnotationStorage {
private const val DIR_NAME = "beat_annotations"
fun annotationsDir(context: Context): File =
File(context.filesDir, DIR_NAME).apply { mkdirs() }
/** /**
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json". * [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
* Documents/Lockstep/{sessionFolder}/.
*/ */
fun writeAnnotationsFile( fun writeAnnotationsFile(
context: Context, context: Context,
sessionFolder: String,
playlistDisplayName: String, playlistDisplayName: String,
trackQueueIndex0Based: Int, trackQueueIndex0Based: Int,
contentId: String, contentId: String,
title: String, title: String,
artist: String, artist: String,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
): File { ): Uri? {
val safeName = val safeName =
playlistDisplayName playlistDisplayName
.replace(Regex("[\\\\/:*?\"<>|]"), "_") .replace(Regex("[\\\\/:*?\"<>|]"), "_")
@@ -35,17 +31,16 @@ object BeatAnnotationStorage {
.take(120) .take(120)
val suffix = val suffix =
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val file = File(annotationsDir(context), "${safeName}_$suffix.json") val fileName = "${safeName}_$suffix.json"
val sec = beatTimesMs.map { it / 1000.0 } val sec = beatTimesMs.map { it / 1000.0 }
val json = val jsonString =
JSONObject().apply { JSONObject().apply {
put("contentId", contentId) put("contentId", contentId)
put("title", title) put("title", title)
put("artist", artist) put("artist", artist)
put("beatTimesSec", JSONArray(sec)) put("beatTimesSec", JSONArray(sec))
} }.toString(2)
file.writeText(json.toString(2)) return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
return file
} }
/** Document id for a content [Uri] when available; otherwise last path segment. */ /** Document id for a content [Uri] when available; otherwise last path segment. */

View File

@@ -0,0 +1,24 @@
package at.lockstep.player.util
data class RunAccelSample(
/** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */
val timestampNanos: Long,
/** ExoPlayer position in ms when this sample was taken — frozen while paused. */
val positionMs: Long,
val values: FloatArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RunAccelSample) return false
return timestampNanos == other.timestampNanos &&
positionMs == other.positionMs &&
values.contentEquals(other.values)
}
override fun hashCode(): Int {
var result = timestampNanos.hashCode()
result = 31 * result + positionMs.hashCode()
result = 31 * result + values.contentHashCode()
return result
}
}

View File

@@ -58,13 +58,22 @@ object RunDataStorage {
put("versionCode", BuildConfig.VERSION_CODE) put("versionCode", BuildConfig.VERSION_CODE)
}.toString() }.toString()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
}
} }
/** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */
fun writePublicJsonFile(
context: Context,
sessionFolder: String,
fileName: String,
jsonString: String,
): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeViaMediaStore(context, sessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString)
}
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray { private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
val array = JSONArray() val array = JSONArray()
for (sample in samples) { for (sample in samples) {

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/ic_launcher_bg" />
</shape>
</item>
</layer-list>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Running shoe, centered in adaptive-icon safe zone -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18,70c0,-4 3,-7 7,-7h4l2,8c8,2 18,3 28,2l32,-6c5,-1 9,2 10,7l1,6c0,4 -3,8 -8,9l-38,7c-16,2 -32,0 -46,-6l-2,-1c-4,-2 -7,-6 -6,-11l4,-8z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M32,52c6,-14 22,-22 38,-20l26,4c8,1 14,8 15,16l1,10c0,3 -2,6 -5,7l-8,2 -30,4c-12,1 -24,-4 -32,-13l-6,-9c-2,-3 -1,-7 1,-10z" />
<path
android:fillAlpha="0.92"
android:fillColor="#FFFFFF"
android:pathData="M48,46l18,-2c4,0 7,3 8,7v2l-20,3c-3,0 -6,-2 -7,-5v-3c0,-1 0,-2 1,-2z" />
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_bg">#E65100</color> <color name="ic_launcher_bg">#ffffff</color>
</resources> </resources>

View File

@@ -25,7 +25,7 @@
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string> <string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_stub_body">More controls will land here in a later milestone.</string> <string name="settings_logout_title">Logout</string>
<string name="settings_logout_spotify">Sign out of Spotify</string> <string name="settings_logout_spotify">Sign out of Spotify</string>
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string> <string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
<string name="settings_annotation_mode">Annotation mode</string> <string name="settings_annotation_mode">Annotation mode</string>