diff --git a/BUGS.md b/BUGS.md
new file mode 100644
index 0000000..da1a5cc
--- /dev/null
+++ b/BUGS.md
@@ -0,0 +1,24 @@
+# Bugs
+
+- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
+
+- "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next."
+
+- "playlists 401" in Playlists Activity should send us back to the Spotify auth screen
+
+- the server should implement some rate limiting / deadman switch / to avoid spamming Spotify API with broken-app API calls
+
+
+## Nice-to
+
+- TrackFileMatching, scoreAgainstLocalHints
+ - these are very rudimentary heuristics to pairing mp3 files to the playlists. check them again.
+
+- Playlist has empty name, id=0gjOliftUAwV48X6KK9EeP
+ - in our server-side api, we might want to log such events too
+.
+
+- (pairing songs) "Possible race: openTree callback may capture an empty tracks list if the folder was picked before LiveData emitted."
+
+
+- toast: "no tracks loaded for this playlist yet. [...]"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3253a46..dcaee3b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
+ id("com.google.devtools.ksp")
}
android {
@@ -14,6 +15,11 @@ android {
targetSdk = 35
versionCode = 1
versionName = "0.1"
+ buildConfigField(
+ "String",
+ "LOCKSTEP_API_BASE_URL",
+ "\"https://api.lockstep.at\"",
+ )
}
buildTypes {
@@ -37,6 +43,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
packaging {
@@ -62,5 +69,24 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-service:2.8.7")
+ implementation("androidx.compose.runtime:runtime-livedata")
+
+ implementation(project(":jukebox"))
+
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ ksp("androidx.room:room-compiler:2.6.1")
+
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+
+ implementation("androidx.media:media:1.7.0")
+ implementation("androidx.browser:browser:1.8.0")
+ implementation("androidx.documentfile:documentfile:1.0.1")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
debugImplementation("androidx.compose.ui:ui-tooling")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 70bc777..d69ff97 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,19 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/at/lockstep/player/FolderPairingResult.kt b/app/src/main/java/at/lockstep/player/FolderPairingResult.kt
new file mode 100644
index 0000000..17c18a5
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/FolderPairingResult.kt
@@ -0,0 +1,14 @@
+package at.lockstep.player
+
+/**
+ * Outcome of bulk folder pairing: counts for UI toasts and debugging.
+ *
+ * [failed] includes rows with no local file match, validation errors, and rows with no Spotify track id.
+ */
+data class FolderPairingResult(
+ val paired: Int,
+ val failed: Int,
+ val jukeboxRowCount: Int,
+ val mp3Count: Int,
+ val skippedNoSpotifyTrackId: Int,
+)
diff --git a/app/src/main/java/at/lockstep/player/LockstepApplication.kt b/app/src/main/java/at/lockstep/player/LockstepApplication.kt
new file mode 100644
index 0000000..f2e3ae5
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/LockstepApplication.kt
@@ -0,0 +1,62 @@
+package at.lockstep.player
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.os.Build
+import at.lockstep.jukebox.Jukebox
+import at.lockstep.jukebox.PlaylistRepository
+import at.lockstep.player.data.db.AppDatabase
+import okhttp3.Interceptor
+import java.util.concurrent.atomic.AtomicReference
+
+class LockstepApplication : Application() {
+ private val spotifyAccessTokenRef = AtomicReference(null)
+
+ val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
+
+ val playlistRepository: PlaylistRepository by lazy {
+ Jukebox.playlistRepository(
+ this,
+ Interceptor { chain ->
+ val token = spotifyAccessTokenRef.get()
+ val req =
+ if (!token.isNullOrBlank()) {
+ chain.request().newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ } else {
+ chain.request()
+ }
+ chain.proceed(req)
+ },
+ "${BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/')}/",
+ )
+ }
+
+ fun setSpotifyAccessTokenForApi(token: String?) {
+ spotifyAccessTokenRef.set(token)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ ensurePlaybackChannel()
+ }
+
+ private fun ensurePlaybackChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = getSystemService(NotificationManager::class.java) ?: return
+ val channel =
+ NotificationChannel(
+ PLAYBACK_NOTIFICATION_CHANNEL_ID,
+ getString(R.string.notification_channel_playback_name),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply { description = getString(R.string.notification_channel_playback_description) }
+ manager.createNotificationChannel(channel)
+ }
+
+ companion object {
+ const val PLAYBACK_NOTIFICATION_CHANNEL_ID = "lockstep_playback"
+ const val PLAYBACK_NOTIFICATION_ID = 1001
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt
new file mode 100644
index 0000000..bf4dd2a
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt
@@ -0,0 +1,319 @@
+package at.lockstep.player
+
+import android.app.Application
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import at.lockstep.jukebox.api.LockstepApiException
+import at.lockstep.jukebox.db.TrackRow
+import at.lockstep.player.data.UserPreferencesRepository
+import at.lockstep.player.data.db.TrackPairingEntity
+import at.lockstep.player.util.AudioUriValidator
+import at.lockstep.player.util.FolderMp3Scanner
+import at.lockstep.player.util.Mp3EmbeddedMetadata
+import at.lockstep.player.util.Mp3FolderCandidate
+import at.lockstep.player.util.TrackFileMatching
+import at.lockstep.player.util.mp3DisplayNameFromUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+
+class LockstepViewModel(
+ application: Application,
+) : AndroidViewModel(application) {
+ private val app = application as LockstepApplication
+
+ companion object {
+ private const val TAG = "LockstepPairing"
+
+ /** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
+ private val playlistDetailMutexes = ConcurrentHashMap()
+ private fun detailMutexFor(playlistId: String): Mutex =
+ playlistDetailMutexes.getOrPut(playlistId) { Mutex() }
+ }
+ private val prefs = UserPreferencesRepository(application)
+ private val pairingDao get() = app.database.pairingDao()
+
+ val onboardingComplete: StateFlow =
+ prefs.onboardingComplete.stateIn(
+ viewModelScope,
+ SharingStarted.Eagerly,
+ false,
+ )
+
+ val spotifyAccessToken: StateFlow =
+ prefs.spotifyAccessToken.stateIn(
+ viewModelScope,
+ SharingStarted.Eagerly,
+ null,
+ )
+
+ private val context get() = getApplication()
+
+ /**
+ * When local [playlistId] has no track rows (common after sync list-only or unchanged snapshot),
+ * fetches [PlaylistRepository.syncPlaylistDetail] once so pairing UI and folder pairing see tracks.
+ */
+ fun ensurePlaylistTracksLoaded(playlistId: String) {
+ viewModelScope.launch {
+ if (spotifyAccessToken.value.isNullOrBlank()) {
+ Log.d(TAG, "ensurePlaylistTracksLoaded: no token, skip fetch for $playlistId")
+ return@launch
+ }
+ loadJukeboxTracksEnsuringDetail(playlistId)
+ }
+ }
+
+ private suspend fun loadJukeboxTracksEnsuringDetail(playlistId: String): List {
+ var rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
+ if (rows.isNotEmpty()) {
+ return rows
+ }
+ return detailMutexFor(playlistId).withLock {
+ rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
+ if (rows.isNotEmpty()) {
+ Log.d(TAG, "jukebox cache filled while waiting for detail mutex playlistId=$playlistId")
+ return@withLock rows
+ }
+ Log.w(
+ TAG,
+ "No tracks in jukebox cache for playlistId=$playlistId - calling syncPlaylistDetail (GET /playlists/{id})",
+ )
+ app.setSpotifyAccessTokenForApi(spotifyAccessToken.value)
+ withContext(Dispatchers.IO) {
+ try {
+ app.playlistRepository.syncPlaylistDetail(playlistId)
+ } catch (e: LockstepApiException) {
+ Log.e(TAG, "syncPlaylistDetail API error for $playlistId", e)
+ } catch (e: IOException) {
+ Log.e(TAG, "syncPlaylistDetail IO error for $playlistId", e)
+ }
+ }
+ rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
+ Log.d(TAG, "After syncPlaylistDetail: track row count=${rows.size} for $playlistId")
+ rows
+ }
+ }
+
+ init {
+ viewModelScope.launch {
+ spotifyAccessToken.collect { token ->
+ app.setSpotifyAccessTokenForApi(token)
+ }
+ }
+ }
+
+ fun observePlaylists() = app.playlistRepository.observePlaylists()
+
+ fun observeJukeboxTracks(playlistId: String) =
+ app.playlistRepository.observeTracks(playlistId)
+
+ fun observePairings(playlistId: String) = pairingDao.observeForPlaylist(playlistId)
+
+ suspend fun hasPairedTracks(playlistId: String): Boolean = pairingDao.countPaired(playlistId) > 0
+
+ suspend fun syncJukeboxIfToken(): String? {
+ val token = spotifyAccessToken.value
+ if (token.isNullOrBlank()) {
+ return "Not signed in"
+ }
+ app.setSpotifyAccessTokenForApi(token)
+ return withContext(Dispatchers.IO) {
+ try {
+ app.playlistRepository.syncInitial()
+ null
+ } catch (e: LockstepApiException) {
+ e.message ?: "Sync failed"
+ } catch (e: IOException) {
+ e.message ?: "Sync failed"
+ }
+ }
+ }
+
+ fun completeOnboarding() {
+ viewModelScope.launch {
+ prefs.setOnboardingComplete(true)
+ }
+ }
+
+ fun saveSpotifyAccessToken(token: String?) {
+ viewModelScope.launch {
+ prefs.setSpotifyAccessToken(token)
+ }
+ }
+
+ private suspend fun upsertPairing(
+ playlistId: String,
+ trackId: String,
+ localUri: String?,
+ pairingError: String?,
+ ) {
+ pairingDao.upsert(
+ TrackPairingEntity(
+ playlistId = playlistId,
+ trackId = trackId,
+ localUri = localUri,
+ pairingError = pairingError,
+ ),
+ )
+ }
+
+ fun pairTrackFromUri(
+ playlistId: String,
+ track: TrackRow,
+ uri: Uri,
+ onResult: (paired: Boolean, message: String?) -> Unit,
+ ) {
+ val trackId = track.trackId
+ if (trackId.isNullOrBlank()) {
+ onResult(false, "No Spotify track id for this row")
+ return
+ }
+ viewModelScope.launch {
+ val err = withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, uri) }
+ if (err != null) {
+ upsertPairing(playlistId, trackId, null, err)
+ onResult(false, err)
+ } else {
+ upsertPairing(playlistId, trackId, uri.toString(), null)
+ onResult(true, null)
+ }
+ }
+ }
+
+ /**
+ * Loads playlist tracks from the jukebox DB on a background thread (not from Compose state) so folder
+ * pairing is never run against a stale or empty list.
+ */
+ fun pairPlaylistFromFolder(
+ playlistId: String,
+ treeUri: Uri,
+ onFinished: (FolderPairingResult) -> Unit,
+ ) {
+ viewModelScope.launch {
+ Log.d(TAG, "pairPlaylistFromFolder playlistId=$playlistId treeUri=$treeUri (will load tracks from jukebox DB)")
+ val persistFlags =
+ android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+ try {
+ context.contentResolver.takePersistableUriPermission(treeUri, persistFlags)
+ Log.d(TAG, "takePersistableUriPermission ok")
+ } catch (e: SecurityException) {
+ Log.w(TAG, "takePersistableUriPermission failed — listing may still work from picker grant", e)
+ }
+
+ val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
+ val withId = tracks.count { !it.trackId.isNullOrBlank() }
+ val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
+ val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
+ Log.d(
+ TAG,
+ "jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
+ "withTitle=$withTitle withArtist=$withArtist",
+ )
+ tracks.take(12).forEach { row ->
+ Log.d(
+ TAG,
+ " jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
+ "artist='${row.artistName}' durationMs=${row.durationMs}",
+ )
+ }
+ if (tracks.size > 12) {
+ Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
+ }
+
+ val (mp3Total, pool) =
+ withContext(Dispatchers.IO) {
+ val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
+ Log.d(TAG, "pairPlaylistFromFolder scan returned mp3Count=${mp3s.size}")
+ val p =
+ mp3s.map { uri ->
+ val (id3Title, id3Artist) = Mp3EmbeddedMetadata.readTitleAndArtist(context, uri)
+ Mp3FolderCandidate(
+ uri = uri,
+ fileBaseName = mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
+ id3Title = id3Title,
+ id3Artist = id3Artist,
+ )
+ }.toMutableList()
+ Pair(mp3s.size, p)
+ }
+
+ if (pool.isEmpty()) {
+ Log.w(TAG, "No MP3 candidates after scan — cannot pair")
+ onFinished(
+ FolderPairingResult(
+ paired = 0,
+ failed = 0,
+ jukeboxRowCount = tracks.size,
+ mp3Count = mp3Total,
+ skippedNoSpotifyTrackId = 0,
+ ),
+ )
+ return@launch
+ }
+
+ Log.d(
+ TAG,
+ "pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
+ "files have title or artist from tags",
+ )
+
+ var paired = 0
+ var failed = 0
+ var skippedNoSpotifyTrackId = 0
+ for (track in tracks.sortedBy { it.position }) {
+ val tid = track.trackId
+ if (tid.isNullOrBlank()) {
+ skippedNoSpotifyTrackId++
+ failed++
+ Log.d(
+ TAG,
+ "skip row pos=${track.position}: no Spotify track id " +
+ "(title='${track.trackName}' artist='${track.artistName}')",
+ )
+ continue
+ }
+ val title = track.trackName.orEmpty()
+ val artist = track.artistName.orEmpty()
+ val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
+ if (pick == null) {
+ failed++
+ continue
+ }
+ val err =
+ withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
+ if (err != null) {
+ Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
+ upsertPairing(playlistId, tid, null, err)
+ failed++
+ } else {
+ upsertPairing(playlistId, tid, pick.toString(), null)
+ paired++
+ pool.removeAll { it.uri == pick }
+ }
+ }
+ Log.d(
+ TAG,
+ "pairPlaylistFromFolder done paired=$paired failed=$failed " +
+ "skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
+ )
+ onFinished(
+ FolderPairingResult(
+ paired = paired,
+ failed = failed,
+ jukeboxRowCount = tracks.size,
+ mp3Count = mp3Total,
+ skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
+ ),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModelFactory.kt b/app/src/main/java/at/lockstep/player/LockstepViewModelFactory.kt
new file mode 100644
index 0000000..dfd8542
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/LockstepViewModelFactory.kt
@@ -0,0 +1,17 @@
+package at.lockstep.player
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+class LockstepViewModelFactory(
+ private val application: Application,
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(LockstepViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ return LockstepViewModel(application) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/MainActivity.kt b/app/src/main/java/at/lockstep/player/MainActivity.kt
index b40fdfe..9b8efd1 100644
--- a/app/src/main/java/at/lockstep/player/MainActivity.kt
+++ b/app/src/main/java/at/lockstep/player/MainActivity.kt
@@ -1,41 +1,71 @@
package at.lockstep.player
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import at.lockstep.player.ui.NowPlayingScreen
-import at.lockstep.player.ui.NowPlayingUiState
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.util.Consumer
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.rememberNavController
+import at.lockstep.player.auth.OAuthCallbackParser
+import at.lockstep.player.ui.LockstepAppNavHost
+import at.lockstep.player.ui.theme.LockstepTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
- MaterialTheme {
- Surface(modifier = Modifier.fillMaxSize()) {
- var progress by remember { mutableFloatStateOf(0f) }
- var playing by remember { mutableStateOf(false) }
- NowPlayingScreen(
- state = NowPlayingUiState(
- title = "No track",
- artist = "—",
- progress = progress,
- durationSeconds = 180,
- isPlaying = playing,
- ),
- onProgressChange = { progress = it },
- onPrevious = { /* TODO: service / JNI */ },
- onTogglePlayPause = { playing = !playing },
- onNext = { /* TODO: service / JNI */ },
+ LockstepTheme {
+ val viewModel: LockstepViewModel =
+ viewModel(
+ factory = LockstepViewModelFactory(application),
)
+ val context = LocalContext.current
+ val onboardingDone by viewModel.onboardingComplete.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ intent?.data?.let { uri ->
+ OAuthCallbackParser.parseAccessToken(uri)?.let { token ->
+ viewModel.saveSpotifyAccessToken(token)
+ }
+ }
+ }
+
+ DisposableEffect(viewModel) {
+ val listener =
+ Consumer { newIntent ->
+ newIntent.data?.let { uri ->
+ OAuthCallbackParser.parseAccessToken(uri)?.let { token ->
+ viewModel.saveSpotifyAccessToken(token)
+ }
+ }
+ }
+ addOnNewIntentListener(listener)
+ onDispose { removeOnNewIntentListener(listener) }
+ }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ if (!onboardingDone) {
+ at.lockstep.player.ui.onboarding.OnboardingScreen(
+ viewModel = viewModel,
+ onFinished = { },
+ )
+ } else {
+ val navController = rememberNavController()
+ LockstepAppNavHost(
+ activity = this@MainActivity,
+ navController = navController,
+ viewModel = viewModel,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/at/lockstep/player/auth/SpotifyLogin.kt b/app/src/main/java/at/lockstep/player/auth/SpotifyLogin.kt
new file mode 100644
index 0000000..cdc2f66
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/auth/SpotifyLogin.kt
@@ -0,0 +1,44 @@
+package at.lockstep.player.auth
+
+import android.content.Context
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.core.net.toUri
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+object SpotifyLogin {
+ private const val LOGIN_BASE = "https://api.lockstep.at/login/spotify/"
+ private const val REDIRECT_URI_STRING = "at.lockstep.player://spotify/callback"
+
+ fun buildLoginUri(): Uri {
+ val redirectEncoded =
+ URLEncoder.encode(REDIRECT_URI_STRING, StandardCharsets.UTF_8.name())
+ return "${LOGIN_BASE}?redirect_uri=$redirectEncoded".toUri()
+ }
+
+ fun launch(
+ context: Context,
+ customTabsIntent: CustomTabsIntent,
+ ) {
+ customTabsIntent.launchUrl(context, buildLoginUri())
+ }
+}
+
+object OAuthCallbackParser {
+ fun parseAccessToken(uri: Uri): String? {
+ uri.getQueryParameter("access_token")?.takeIf { it.isNotBlank() }?.let { return it }
+ val fragment = uri.encodedFragment ?: return null
+ fragment.split("&").forEach { part ->
+ val idx = part.indexOf('=')
+ if (idx <= 0) return@forEach
+ val key = part.substring(0, idx)
+ val value = part.substring(idx + 1)
+ if (key == "access_token" && value.isNotBlank()) {
+ return URLDecoder.decode(value, StandardCharsets.UTF_8.name())
+ }
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/data/TrackBpmDefaults.kt b/app/src/main/java/at/lockstep/player/data/TrackBpmDefaults.kt
new file mode 100644
index 0000000..b596f07
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/data/TrackBpmDefaults.kt
@@ -0,0 +1,10 @@
+package at.lockstep.player.data
+
+/**
+ * Nominal track tempo until [api.lockstep.at](https://api.lockstep.at) provides per-track BPM.
+ * UI step frequency remains "--" for MVP; this value is for future ordering / adaptation.
+ */
+object TrackBpmDefaults {
+ /** Placeholder field matching the future API contract. */
+ const val FALLBACK_NOMINAL_BPM: Double = 128.0
+}
diff --git a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt
new file mode 100644
index 0000000..61a4239
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt
@@ -0,0 +1,50 @@
+package at.lockstep.player.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "lockstep_settings")
+
+class UserPreferencesRepository(
+ context: Context,
+) {
+ private val dataStore = context.applicationContext.settingsDataStore
+
+ val onboardingComplete: Flow =
+ dataStore.data.map { prefs ->
+ prefs[KEY_ONBOARDING_COMPLETE] == true
+ }
+
+ val spotifyAccessToken: Flow =
+ dataStore.data.map { prefs ->
+ prefs[KEY_SPOTIFY_ACCESS_TOKEN]
+ }
+
+ suspend fun setOnboardingComplete(done: Boolean) {
+ dataStore.edit { prefs ->
+ prefs[KEY_ONBOARDING_COMPLETE] = done
+ }
+ }
+
+ suspend fun setSpotifyAccessToken(token: String?) {
+ dataStore.edit { prefs ->
+ if (token == null) {
+ prefs.remove(KEY_SPOTIFY_ACCESS_TOKEN)
+ } else {
+ prefs[KEY_SPOTIFY_ACCESS_TOKEN] = token
+ }
+ }
+ }
+
+ companion object {
+ private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
+ private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt
new file mode 100644
index 0000000..e51c271
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt
@@ -0,0 +1,37 @@
+package at.lockstep.player.data.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [TrackPairingEntity::class],
+ version = 2,
+ exportSchema = false,
+)
+abstract class AppDatabase :
+ RoomDatabase() {
+ abstract fun pairingDao(): PairingDao
+
+ companion object {
+ private const val DB_NAME = "lockstep.db"
+
+ @Volatile
+ private var instance: AppDatabase? = null
+
+ fun getInstance(context: Context): AppDatabase {
+ return instance
+ ?: synchronized(this) {
+ instance
+ ?: Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ DB_NAME,
+ ).fallbackToDestructiveMigration()
+ .build()
+ .also { instance = it }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt b/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt
new file mode 100644
index 0000000..ef9e8a3
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt
@@ -0,0 +1,29 @@
+package at.lockstep.player.data.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface PairingDao {
+ @Query(
+ """
+ SELECT COUNT(*) FROM track_pairings
+ WHERE playlistId = :playlistId
+ AND localUri IS NOT NULL
+ AND pairingError IS NULL
+ """,
+ )
+ suspend fun countPaired(playlistId: String): Int
+
+ @Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
+ fun observeForPlaylist(playlistId: String): Flow>
+
+ @Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
+ suspend fun listForPlaylist(playlistId: String): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(row: TrackPairingEntity)
+}
diff --git a/app/src/main/java/at/lockstep/player/data/db/TrackPairingEntity.kt b/app/src/main/java/at/lockstep/player/data/db/TrackPairingEntity.kt
new file mode 100644
index 0000000..5f2ddd8
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/data/db/TrackPairingEntity.kt
@@ -0,0 +1,14 @@
+package at.lockstep.player.data.db
+
+import androidx.room.Entity
+
+@Entity(
+ tableName = "track_pairings",
+ primaryKeys = ["playlistId", "trackId"],
+)
+data class TrackPairingEntity(
+ val playlistId: String,
+ val trackId: String,
+ val localUri: String?,
+ val pairingError: String?,
+)
diff --git a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
new file mode 100644
index 0000000..1229ef8
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
@@ -0,0 +1,375 @@
+package at.lockstep.player.playback
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.Uri
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.core.app.NotificationCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import at.lockstep.player.LockstepApplication
+import at.lockstep.player.MainActivity
+import at.lockstep.player.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class PlaybackService : Service() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private lateinit var mediaSession: MediaSessionCompat
+ private lateinit var app: LockstepApplication
+
+ private val binder = LocalBinder()
+
+ private val _uiState = MutableStateFlow(PlaybackUiState.initial())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var queue: List = emptyList()
+ private var index: Int = 0
+
+ inner class LocalBinder : Binder() {
+ fun getService(): PlaybackService = this@PlaybackService
+ }
+
+ override fun onBind(intent: Intent?): IBinder = binder
+
+ override fun onCreate() {
+ super.onCreate()
+ app = application as LockstepApplication
+ mediaSession = MediaSessionCompat(this, "LockstepPlayback")
+ mediaSession.setCallback(
+ object : MediaSessionCompat.Callback() {
+ override fun onPlay() {
+ setPlaying(true)
+ }
+
+ override fun onPause() {
+ setPlaying(false)
+ }
+
+ override fun onSkipToNext() {
+ skipDelta(1)
+ }
+
+ override fun onSkipToPrevious() {
+ skipDelta(-1)
+ }
+ },
+ )
+ mediaSession.isActive = true
+ }
+
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
+ when (intent?.action) {
+ ACTION_START_PLAYLIST -> {
+ val pid = intent.getStringExtra(EXTRA_PLAYLIST_ID)
+ if (pid != null) {
+ goForegroundPlaceholder()
+ startPlaylist(pid)
+ }
+ }
+ ACTION_TOGGLE_PAUSE -> togglePlaying()
+ ACTION_SKIP_NEXT -> skipDelta(1)
+ ACTION_SKIP_PREVIOUS -> skipDelta(-1)
+ }
+ return START_STICKY
+ }
+
+ private fun goForegroundPlaceholder() {
+ val notification =
+ NotificationCompat.Builder(
+ this,
+ LockstepApplication.PLAYBACK_NOTIFICATION_CHANNEL_ID,
+ ).setContentTitle(getString(R.string.notification_loading_playlist))
+ .setContentText(getString(R.string.app_name))
+ .setSmallIcon(R.drawable.ic_play_arrow_24)
+ .setContentIntent(contentActivityIntent())
+ .setOngoing(true)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ LockstepApplication.PLAYBACK_NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
+ )
+ } else {
+ startForeground(
+ LockstepApplication.PLAYBACK_NOTIFICATION_ID,
+ notification,
+ )
+ }
+ }
+
+ private fun startPlaylist(pid: String) {
+ scope.launch(Dispatchers.IO) {
+ val rows = app.playlistRepository.getTracks(pid)
+ val pairingByTrackId =
+ app.database.pairingDao().listForPlaylist(pid).associateBy { it.trackId }
+ queue =
+ rows.mapNotNull { row ->
+ val tid = row.trackId ?: return@mapNotNull null
+ val pairing = pairingByTrackId[tid] ?: return@mapNotNull null
+ val uriStr = pairing.localUri
+ if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
+ return@mapNotNull null
+ }
+ TrackQueueItem(
+ id = tid,
+ title = row.trackName ?: "—",
+ artist = row.artistName ?: "—",
+ localUri = Uri.parse(uriStr),
+ )
+ }
+ index = 0
+ if (queue.isEmpty()) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return@launch
+ }
+ withContext(Dispatchers.Main) {
+ setPlaying(true)
+ publishCurrentTrack()
+ }
+ }
+ }
+
+ private fun publishCurrentTrack() {
+ val item = queue.getOrNull(index) ?: return
+ val durationSec = 180
+ _uiState.value =
+ PlaybackUiState(
+ title = item.title,
+ artist = item.artist,
+ progress = _uiState.value.progress,
+ durationSeconds = durationSec,
+ isPlaying = _uiState.value.isPlaying,
+ )
+ updateSessionMetadata(
+ item,
+ durationSec,
+ )
+ updatePlaybackState()
+ refreshForegroundNotification()
+ }
+
+ private fun updateSessionMetadata(
+ item: TrackQueueItem,
+ durationSec: Int,
+ ) {
+ mediaSession.setMetadata(
+ MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.title)
+ .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.artist)
+ .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationSec * 1000L)
+ .build(),
+ )
+ }
+
+ private fun updatePlaybackState() {
+ val actions =
+ PlaybackStateCompat.ACTION_PLAY or
+ PlaybackStateCompat.ACTION_PAUSE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
+ val state =
+ if (_uiState.value.isPlaying) {
+ PlaybackStateCompat.STATE_PLAYING
+ } else {
+ PlaybackStateCompat.STATE_PAUSED
+ }
+ mediaSession.setPlaybackState(
+ PlaybackStateCompat.Builder()
+ .setActions(actions)
+ .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
+ .build(),
+ )
+ }
+
+ private fun setPlaying(playing: Boolean) {
+ _uiState.value = _uiState.value.copy(isPlaying = playing)
+ updatePlaybackState()
+ refreshForegroundNotification()
+ }
+
+ private fun togglePlaying() {
+ if (queue.isEmpty()) return
+ setPlaying(!_uiState.value.isPlaying)
+ }
+
+ private fun skipDelta(delta: Int) {
+ if (queue.isEmpty()) return
+ index = (index + delta).coerceIn(0, queue.lastIndex)
+ publishCurrentTrack()
+ }
+
+ fun requestTogglePause() {
+ togglePlaying()
+ }
+
+ fun requestSkipNext() {
+ skipDelta(1)
+ }
+
+ fun requestSkipPrevious() {
+ skipDelta(-1)
+ }
+
+ fun requestSeek(fraction: Float) {
+ if (queue.isEmpty()) return
+ _uiState.value =
+ _uiState.value.copy(
+ progress = fraction.coerceIn(0f, 1f),
+ )
+ mediaSession.setPlaybackState(
+ PlaybackStateCompat.Builder()
+ .setActions(
+ PlaybackStateCompat.ACTION_PLAY or
+ PlaybackStateCompat.ACTION_PAUSE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackStateCompat.ACTION_SEEK_TO,
+ )
+ .setState(
+ if (_uiState.value.isPlaying) {
+ PlaybackStateCompat.STATE_PLAYING
+ } else {
+ PlaybackStateCompat.STATE_PAUSED
+ },
+ (_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(),
+ 1f,
+ )
+ .build(),
+ )
+ refreshForegroundNotification()
+ }
+
+ private fun refreshForegroundNotification() {
+ if (queue.isEmpty()) return
+ val notification = buildNotification()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ LockstepApplication.PLAYBACK_NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
+ )
+ } else {
+ startForeground(
+ LockstepApplication.PLAYBACK_NOTIFICATION_ID,
+ notification,
+ )
+ }
+ }
+
+ private fun contentActivityIntent(): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private fun buildNotification(): android.app.Notification {
+ val prev =
+ PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, PlaybackService::class.java).setAction(ACTION_SKIP_PREVIOUS),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val next =
+ PendingIntent.getService(
+ this,
+ 2,
+ Intent(this, PlaybackService::class.java).setAction(ACTION_SKIP_NEXT),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val toggle =
+ PendingIntent.getService(
+ this,
+ 3,
+ Intent(this, PlaybackService::class.java).setAction(ACTION_TOGGLE_PAUSE),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val playing = _uiState.value.isPlaying
+ val style =
+ MediaStyle()
+ .setMediaSession(mediaSession.sessionToken)
+ .setShowActionsInCompactView(0, 1, 2)
+
+ return NotificationCompat.Builder(
+ this,
+ LockstepApplication.PLAYBACK_NOTIFICATION_CHANNEL_ID,
+ ).setContentTitle(_uiState.value.title)
+ .setContentText(_uiState.value.artist)
+ .setSmallIcon(R.drawable.ic_play_arrow_24)
+ .setContentIntent(contentActivityIntent())
+ .setOnlyAlertOnce(true)
+ .addAction(R.drawable.ic_skip_previous_24, getString(R.string.notification_prev), prev)
+ .addAction(
+ if (playing) R.drawable.ic_pause_24 else R.drawable.ic_play_arrow_24,
+ getString(R.string.notification_play_pause),
+ toggle,
+ )
+ .addAction(R.drawable.ic_skip_next_24, getString(R.string.notification_next), next)
+ .setStyle(style)
+ .build()
+ }
+
+ override fun onDestroy() {
+ mediaSession.run {
+ isActive = false
+ release()
+ }
+ super.onDestroy()
+ }
+
+ data class PlaybackUiState(
+ val title: String,
+ val artist: String,
+ val progress: Float,
+ val durationSeconds: Int,
+ val isPlaying: Boolean,
+ ) {
+ companion object {
+ fun initial() =
+ PlaybackUiState(
+ title = "—",
+ artist = "—",
+ progress = 0f,
+ durationSeconds = 180,
+ isPlaying = false,
+ )
+ }
+ }
+
+ private data class TrackQueueItem(
+ val id: String,
+ val title: String,
+ val artist: String,
+ val localUri: Uri?,
+ )
+
+ companion object {
+ const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
+ const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
+ const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"
+ const val ACTION_SKIP_PREVIOUS = "at.lockstep.player.action.SKIP_PREVIOUS"
+ const val EXTRA_PLAYLIST_ID = "playlist_id"
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
new file mode 100644
index 0000000..ac1ea12
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
@@ -0,0 +1,128 @@
+package at.lockstep.player.ui
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import at.lockstep.jukebox.PlaylistSummary
+import at.lockstep.player.LockstepViewModel
+import at.lockstep.player.playback.PlaybackService
+import at.lockstep.player.ui.library.LibraryScreen
+import at.lockstep.player.ui.navigation.Routes
+import at.lockstep.player.ui.pairing.PairingScreen
+import at.lockstep.player.ui.settings.SettingsScreen
+
+@Composable
+fun LockstepAppNavHost(
+ activity: ComponentActivity,
+ navController: NavHostController,
+ viewModel: LockstepViewModel,
+) {
+ var playback by remember { mutableStateOf(null) }
+
+ DisposableEffect(Unit) {
+ val connection =
+ object : ServiceConnection {
+ override fun onServiceConnected(
+ name: ComponentName?,
+ binder: IBinder?,
+ ) {
+ playback = (binder as PlaybackService.LocalBinder).getService()
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ playback = null
+ }
+ }
+ activity.bindService(
+ Intent(activity, PlaybackService::class.java),
+ connection,
+ Context.BIND_AUTO_CREATE,
+ )
+ onDispose {
+ activity.unbindService(connection)
+ }
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = Routes.Library,
+ ) {
+ composable(Routes.Library) {
+ LibraryScreen(
+ viewModel = viewModel,
+ onPlaylistSelected = { playlist: PlaylistSummary, hasPaired ->
+ if (!hasPaired) {
+ navController.navigate(Routes.pairing(playlist.id))
+ } else {
+ startPlaylistPlayback(activity, playlist.id)
+ navController.navigate(Routes.nowPlaying(playlist.id))
+ }
+ },
+ onOpenSettings = {
+ navController.navigate(Routes.Settings)
+ },
+ )
+ }
+ composable(Routes.Settings) {
+ SettingsScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(
+ route = Routes.Pairing,
+ arguments =
+ listOf(
+ navArgument("playlistId") { type = NavType.StringType },
+ ),
+ ) { entry ->
+ val playlistId = entry.arguments?.getString("playlistId").orEmpty()
+ PairingScreen(
+ playlistId = playlistId,
+ viewModel = viewModel,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(
+ route = Routes.NowPlaying,
+ arguments =
+ listOf(
+ navArgument("playlistId") { type = NavType.StringType },
+ ),
+ ) { entry ->
+ val playlistId = entry.arguments?.getString("playlistId").orEmpty()
+ NowPlayingRoute(
+ playlistId = playlistId,
+ playback = playback,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ }
+}
+
+private fun startPlaylistPlayback(
+ context: Context,
+ playlistId: String,
+) {
+ ContextCompat.startForegroundService(
+ context,
+ Intent(context, PlaybackService::class.java).apply {
+ action = PlaybackService.ACTION_START_PLAYLIST
+ putExtra(PlaybackService.EXTRA_PLAYLIST_ID, playlistId)
+ },
+ )
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt
index b9f24a8..6adcb5b 100644
--- a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt
+++ b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt
@@ -8,16 +8,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -25,13 +30,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import at.lockstep.player.playback.PlaybackService
+import at.lockstep.player.R
data class NowPlayingUiState(
val title: String,
val artist: String,
+ /** Placeholder until live cadence / JNI wiring; MVP shows "--". */
+ val stepFrequencyDisplay: String,
/** 0f..1f until real timing is wired to Oboe / JNI. */
val progress: Float,
/** Total track length in seconds (placeholder for UI formatting). */
@@ -51,11 +61,25 @@ fun NowPlayingScreen(
val elapsed = (state.progress * state.durationSeconds).toInt().coerceIn(0, state.durationSeconds)
Column(
- modifier = modifier
- .fillMaxSize()
- .padding(horizontal = 24.dp, vertical = 16.dp),
+ modifier =
+ modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = LocalContext.current.getString(R.string.now_playing_step_frequency_label),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = state.stepFrequencyDisplay,
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = state.title,
@@ -124,6 +148,70 @@ fun NowPlayingScreen(
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NowPlayingRoute(
+ playlistId: String,
+ playback: PlaybackService?,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var ui by remember {
+ mutableStateOf(
+ NowPlayingUiState(
+ title = context.getString(R.string.now_playing_idle_title),
+ artist = context.getString(R.string.now_playing_idle_artist),
+ stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
+ progress = 0f,
+ durationSeconds = 180,
+ isPlaying = false,
+ ),
+ )
+ }
+
+ LaunchedEffect(playback) {
+ val service = playback ?: return@LaunchedEffect
+ service.uiState.collect { p ->
+ ui =
+ NowPlayingUiState(
+ title = p.title,
+ artist = p.artist,
+ stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
+ progress = p.progress,
+ durationSeconds = p.durationSeconds,
+ isPlaying = p.isPlaying,
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(text = context.getString(R.string.now_playing_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ NowPlayingScreen(
+ state = ui,
+ onProgressChange = { fraction ->
+ playback?.requestSeek(fraction)
+ ui = ui.copy(progress = fraction)
+ },
+ onPrevious = { playback?.requestSkipPrevious() },
+ onTogglePlayPause = { playback?.requestTogglePause() },
+ onNext = { playback?.requestSkipNext() },
+ modifier = Modifier.padding(padding),
+ )
+ }
+}
+
private fun formatMmSs(totalSeconds: Int): String {
val s = totalSeconds.coerceAtLeast(0)
val m = s / 60
@@ -138,13 +226,15 @@ private fun NowPlayingScreenPreview() {
var progress by remember { mutableFloatStateOf(0.35f) }
var playing by remember { mutableStateOf(true) }
NowPlayingScreen(
- state = NowPlayingUiState(
- title = "Example Track Title",
- artist = "Example Artist",
- progress = progress,
- durationSeconds = 215,
- isPlaying = playing,
- ),
+ state =
+ NowPlayingUiState(
+ title = "Example Track Title",
+ artist = "Example Artist",
+ stepFrequencyDisplay = "--",
+ progress = progress,
+ durationSeconds = 215,
+ isPlaying = playing,
+ ),
onProgressChange = { progress = it },
onPrevious = {},
onTogglePlayPause = { playing = !playing },
diff --git a/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt b/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
new file mode 100644
index 0000000..a44c847
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
@@ -0,0 +1,109 @@
+package at.lockstep.player.ui.library
+
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import at.lockstep.jukebox.PlaylistSummary
+import at.lockstep.player.LockstepViewModel
+import at.lockstep.player.R
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LibraryScreen(
+ viewModel: LockstepViewModel,
+ onPlaylistSelected: (PlaylistSummary, Boolean) -> Unit,
+ onOpenSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val token by viewModel.spotifyAccessToken.collectAsStateWithLifecycle()
+ val playlists by viewModel.observePlaylists().observeAsState(initial = emptyList())
+
+ LaunchedEffect(token) {
+ if (token.isNullOrBlank()) return@LaunchedEffect
+ val err = viewModel.syncJukeboxIfToken()
+ if (err != null) {
+ Toast.makeText(context, err, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(text = context.getString(R.string.library_title)) },
+ actions = {
+ IconButton(onClick = onOpenSettings) {
+ Icon(Icons.Default.Settings, contentDescription = context.getString(R.string.settings_title))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(playlists, key = { it.id }) { playlist ->
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ scope.launch {
+ val hasPaired =
+ viewModel.hasPairedTracks(playlist.id)
+ onPlaylistSelected(playlist, hasPaired)
+ }
+ },
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ ListItem(
+ headlineContent = { Text(playlist.name) },
+ supportingContent = {
+ Text(
+ text = context.getString(R.string.library_open_playlist),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt b/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
new file mode 100644
index 0000000..1d2e965
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
@@ -0,0 +1,13 @@
+package at.lockstep.player.ui.navigation
+
+object Routes {
+ const val Onboarding = "onboarding"
+ const val Library = "library"
+ const val Settings = "settings"
+ const val Pairing = "pairing/{playlistId}"
+ const val NowPlaying = "nowPlaying/{playlistId}"
+
+ fun pairing(playlistId: String) = "pairing/$playlistId"
+
+ fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId"
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/at/lockstep/player/ui/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..5d7c777
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/onboarding/OnboardingScreen.kt
@@ -0,0 +1,123 @@
+package at.lockstep.player.ui.onboarding
+
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import at.lockstep.player.LockstepViewModel
+import at.lockstep.player.R
+import at.lockstep.player.auth.SpotifyLogin
+
+@Composable
+fun OnboardingScreen(
+ viewModel: LockstepViewModel,
+ onFinished: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var step by remember { mutableIntStateOf(0) }
+ val token by viewModel.spotifyAccessToken.collectAsStateWithLifecycle()
+
+ val notificationsLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) {
+ step = 1
+ }
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = context.getString(R.string.onboarding_title),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ when (step) {
+ 0 -> {
+ Text(
+ text = context.getString(R.string.onboarding_notifications_body),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ step = 1
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = context.getString(R.string.onboarding_notifications_cta))
+ }
+ }
+ 1 -> {
+ Text(
+ text = context.getString(R.string.onboarding_spotify_body),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (token != null) {
+ Text(
+ text = context.getString(R.string.onboarding_spotify_connected),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Button(
+ onClick = {
+ val tabs = CustomTabsIntent.Builder().build()
+ SpotifyLogin.launch(context, tabs)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = context.getString(R.string.onboarding_spotify_open_browser))
+ }
+ Button(
+ onClick = {
+ viewModel.completeOnboarding()
+ onFinished()
+ },
+ enabled = !token.isNullOrBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = context.getString(R.string.onboarding_continue_signed_in))
+ }
+ TextButton(
+ onClick = {
+ viewModel.completeOnboarding()
+ onFinished()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = context.getString(R.string.onboarding_continue_without_spotify))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt b/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
new file mode 100644
index 0000000..2b459df
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
@@ -0,0 +1,217 @@
+package at.lockstep.player.ui.pairing
+
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import at.lockstep.jukebox.db.TrackRow
+import at.lockstep.player.LockstepViewModel
+import at.lockstep.player.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PairingScreen(
+ playlistId: String,
+ viewModel: LockstepViewModel,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val tracks by viewModel.observeJukeboxTracks(playlistId).observeAsState(initial = emptyList())
+ val pairings by viewModel.observePairings(playlistId).collectAsStateWithLifecycle(emptyList())
+ val pairingByTrackId = pairings.associateBy { it.trackId }
+ var pendingTrack by remember { mutableStateOf(null) }
+
+ LaunchedEffect(playlistId) {
+ viewModel.ensurePlaylistTracksLoaded(playlistId)
+ }
+
+ val openDoc =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.OpenDocument(),
+ onResult = { uri ->
+ val track = pendingTrack
+ pendingTrack = null
+ if (track != null && uri != null) {
+ viewModel.pairTrackFromUri(playlistId, track, uri) { ok, message ->
+ if (!ok && message != null) {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ },
+ )
+
+ val openTree =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.OpenDocumentTree(),
+ onResult = { tree ->
+ if (tree != null) {
+ viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
+ when {
+ r.mp3Count == 0 ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_no_mp3_in_folder),
+ Toast.LENGTH_SHORT,
+ ).show()
+
+ r.jukeboxRowCount == 0 ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_jukebox_empty),
+ Toast.LENGTH_LONG,
+ ).show()
+
+ r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_all_missing_spotify_id),
+ Toast.LENGTH_LONG,
+ ).show()
+
+ r.paired == 0 && r.failed == 0 ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_no_mp3_in_folder),
+ Toast.LENGTH_SHORT,
+ ).show()
+
+ r.failed > 0 ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
+ Toast.LENGTH_LONG,
+ ).show()
+
+ else ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.pairing_folder_ok, r.paired),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+ },
+ )
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(text = context.getString(R.string.pairing_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Button(
+ onClick = { openTree.launch(null) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(context.getString(R.string.pairing_choose_folder))
+ }
+ }
+ items(tracks, key = { it.trackId ?: "removed-${it.position}" }) { track ->
+ val p = track.trackId?.let { pairingByTrackId[it] }
+ val paired = p?.localUri != null && p.pairingError == null
+ val muted = !paired
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ pendingTrack = track
+ openDoc.launch(arrayOf("audio/mpeg", "audio/*"))
+ },
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (muted) {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ ),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = track.trackName ?: context.getString(R.string.pairing_unknown_track),
+ color =
+ if (muted) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ },
+ supportingContent = {
+ val sub =
+ when {
+ paired ->
+ context.getString(
+ R.string.pairing_status_paired,
+ )
+ p?.pairingError != null ->
+ p.pairingError
+ else ->
+ context.getString(
+ R.string.pairing_status_unpaired,
+ )
+ }
+ Text(
+ text = sub,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..e03f136
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt
@@ -0,0 +1,56 @@
+package at.lockstep.player.ui.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import at.lockstep.player.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(text = context.getString(R.string.settings_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = context.getString(R.string.settings_stub_body),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/ui/theme/Color.kt b/app/src/main/java/at/lockstep/player/ui/theme/Color.kt
new file mode 100644
index 0000000..dd34c63
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package at.lockstep.player.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val LockstepOrange = Color(0xFFE65100)
+val LockstepOrangeLight = Color(0xFFFF833A)
+val LockstepBlueDark = Color(0xFF0D47A1)
+val LockstepBlue = Color(0xFF1976D2)
+val LockstepBlueDeep = Color(0xFF002171)
+val LockstepSurfaceDark = Color(0xFF0B1220)
+val LockstepSurfaceLight = Color(0xFFFFF8F2)
diff --git a/app/src/main/java/at/lockstep/player/ui/theme/Theme.kt b/app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
new file mode 100644
index 0000000..9b781d6
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
@@ -0,0 +1,57 @@
+package at.lockstep.player.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+private val LightScheme =
+ lightColorScheme(
+ primary = LockstepOrange,
+ onPrimary = Color.White,
+ primaryContainer = LockstepOrangeLight,
+ onPrimaryContainer = Color.Black,
+ secondary = LockstepBlue,
+ onSecondary = Color.White,
+ secondaryContainer = LockstepBlue.copy(alpha = 0.35f),
+ onSecondaryContainer = LockstepBlueDeep,
+ background = LockstepSurfaceLight,
+ onBackground = LockstepBlueDeep,
+ surface = Color(0xFFFFFBF7),
+ onSurface = LockstepBlueDeep,
+ surfaceVariant = Color(0xFFE8EEF7),
+ onSurfaceVariant = Color(0xFF30343B),
+ outline = LockstepBlue.copy(alpha = 0.45f),
+ )
+
+private val DarkScheme =
+ darkColorScheme(
+ primary = LockstepOrangeLight,
+ onPrimary = Color.Black,
+ primaryContainer = LockstepOrange,
+ onPrimaryContainer = Color.White,
+ secondary = LockstepBlue,
+ onSecondary = Color.White,
+ secondaryContainer = LockstepBlueDark,
+ onSecondaryContainer = Color(0xFFD0E4FF),
+ background = LockstepSurfaceDark,
+ onBackground = Color(0xFFEEF2FA),
+ surface = LockstepSurfaceDark,
+ onSurface = Color(0xFFEEF2FA),
+ surfaceVariant = Color(0xFF1E2836),
+ onSurfaceVariant = Color(0xFFC3CAD6),
+ outline = LockstepOrangeLight.copy(alpha = 0.45f),
+ )
+
+@Composable
+fun LockstepTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ MaterialTheme(
+ colorScheme = if (darkTheme) DarkScheme else LightScheme,
+ content = content,
+ )
+}
diff --git a/app/src/main/java/at/lockstep/player/util/AudioUriValidator.kt b/app/src/main/java/at/lockstep/player/util/AudioUriValidator.kt
new file mode 100644
index 0000000..1202084
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/util/AudioUriValidator.kt
@@ -0,0 +1,26 @@
+package at.lockstep.player.util
+
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+
+object AudioUriValidator {
+ /** @return null if readable, or a short error reason for UI / toasts. */
+ fun validateReadableAudio(
+ context: Context,
+ uri: Uri,
+ ): String? {
+ val retriever = MediaMetadataRetriever()
+ return try {
+ retriever.setDataSource(context.applicationContext, uri)
+ null
+ } catch (e: RuntimeException) {
+ e.message?.takeIf { it.isNotBlank() } ?: "Could not read this audio file"
+ } finally {
+ try {
+ retriever.release()
+ } catch (_: RuntimeException) {
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt b/app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
new file mode 100644
index 0000000..19b17b0
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
@@ -0,0 +1,118 @@
+package at.lockstep.player.util
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+
+object FolderMp3Scanner {
+ private const val TAG = "LockstepPairing"
+
+ fun listMp3Uris(
+ context: Context,
+ treeUri: Uri,
+ ): List {
+ Log.d(TAG, "listMp3Uris start treeUri=$treeUri")
+ val root =
+ DocumentFile.fromTreeUri(context.applicationContext, treeUri) ?: run {
+ Log.w(
+ TAG,
+ "DocumentFile.fromTreeUri returned null — wrong URI or missing persistable read permission for this tree",
+ )
+ return emptyList()
+ }
+
+ Log.d(
+ TAG,
+ "root document name=${root.name} uri=${root.uri} isDirectory=${root.isDirectory} canRead=${root.canRead()}",
+ )
+
+ if (!root.isDirectory) {
+ Log.w(TAG, "root is not a directory — SAF root may be wrong")
+ }
+
+ val out = mutableListOf()
+ val stats = ScanStats()
+ walk(root, out, stats, depth = 0)
+
+ Log.d(
+ TAG,
+ "listMp3Uris done mp3Count=${out.size} fileNodes=${stats.fileCount} dirNodes=${stats.dirCount} nonMp3Files=${stats.nonMp3Files}",
+ )
+
+ if (out.isEmpty()) {
+ when {
+ stats.fileCount > 0 ->
+ Log.d(
+ TAG,
+ "Found files but no MP3 matches. Sample non-MP3 (name + MIME): ${stats.sampleNames.take(15)}",
+ )
+ stats.dirCount > 0 && stats.fileCount == 0 ->
+ Log.w(
+ TAG,
+ "Only subfolders seen under root, no files at any depth — unusual for a music folder",
+ )
+ else ->
+ Log.w(
+ TAG,
+ "No files and no subdirs counted — root listFiles empty/null, or permission blocked listing",
+ )
+ }
+ }
+
+ return out.distinct().sortedBy { mp3DisplayNameFromUri(it).lowercase() }
+ }
+
+ private data class ScanStats(
+ var fileCount: Int = 0,
+ var dirCount: Int = 0,
+ var nonMp3Files: Int = 0,
+ val sampleNames: MutableList = mutableListOf(),
+ )
+
+ private fun walk(
+ dir: DocumentFile,
+ out: MutableList,
+ stats: ScanStats,
+ depth: Int,
+ ) {
+ val children =
+ dir.listFiles() ?: run {
+ Log.w(TAG, "listFiles() returned null name=${dir.name} uri=${dir.uri} depth=$depth")
+ return
+ }
+
+ if (depth == 0) {
+ Log.d(TAG, "root listFiles count=${children.size}")
+ }
+
+ if (children.isEmpty() && depth == 0) {
+ Log.w(TAG, "root has zero entries — empty folder or cannot list (permission / provider)")
+ }
+
+ for (child in children) {
+ if (child.isDirectory) {
+ stats.dirCount++
+ walk(child, out, stats, depth + 1)
+ } else {
+ stats.fileCount++
+ val name = child.name
+ val type = child.type
+ val isMp3 =
+ type == "audio/mpeg" ||
+ (name != null && name.endsWith(".mp3", ignoreCase = true))
+ if (isMp3) {
+ if (depth <= 1) {
+ Log.d(TAG, "mp3 depth=$depth name=$name type=$type")
+ }
+ out.add(child.uri)
+ } else {
+ stats.nonMp3Files++
+ if (stats.sampleNames.size < 25) {
+ stats.sampleNames.add("${name ?: "(null name)"} type=${type ?: "(null)"}")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/util/Mp3EmbeddedMetadata.kt b/app/src/main/java/at/lockstep/player/util/Mp3EmbeddedMetadata.kt
new file mode 100644
index 0000000..c453fd5
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/util/Mp3EmbeddedMetadata.kt
@@ -0,0 +1,64 @@
+package at.lockstep.player.util
+
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.util.Log
+
+/** Reads common ID3-style tags from audio at a content [Uri] (SAF / MediaStore). */
+object Mp3EmbeddedMetadata {
+
+ private const val TAG = "LockstepPairing"
+
+ /**
+ * Returns trimmed title and artist when present. Artist falls back to album artist.
+ * On any failure returns [Pair] of nulls.
+ */
+ fun readTitleAndArtist(
+ context: Context,
+ uri: Uri,
+ ): Pair {
+ val label = mp3DisplayNameFromUri(uri).substringBeforeLast('.').ifBlank { uri.lastPathSegment ?: uri.toString() }
+ val retriever = MediaMetadataRetriever()
+ return try {
+ retriever.setDataSource(context.applicationContext, uri)
+ val title =
+ retriever
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ val artistFromTrack =
+ retriever
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ val albumArtist =
+ retriever
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST)
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ val artist = artistFromTrack ?: albumArtist
+ when {
+ title != null || artist != null ->
+ Log.d(
+ TAG,
+ "id3 ok file=$label title=${title ?: "—"} artist=${artist ?: "—"}",
+ )
+ else ->
+ Log.d(TAG, "id3 empty file=$label (retriever returned no title/artist)")
+ }
+ Pair(title, artist)
+ } catch (e: Exception) {
+ Log.w(
+ TAG,
+ "id3 error file=$label ${e.javaClass.simpleName}: ${e.message}",
+ )
+ Pair(null, null)
+ } finally {
+ try {
+ retriever.release()
+ } catch (_: Exception) {
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt b/app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
new file mode 100644
index 0000000..9eac09d
--- /dev/null
+++ b/app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
@@ -0,0 +1,124 @@
+package at.lockstep.player.util
+
+import android.net.Uri
+import android.util.Log
+
+private fun normalizeToken(value: String): String =
+ value.lowercase().replace(Regex("[^a-z0-9]+"), " ").trim()
+
+fun mp3DisplayNameFromUri(uri: Uri): String {
+ val last = uri.lastPathSegment.orEmpty()
+ val noPercent =
+ try {
+ java.net.URLDecoder.decode(last, java.nio.charset.StandardCharsets.UTF_8.name())
+ } catch (_: Exception) {
+ last
+ }
+ return noPercent.substringAfterLast(':').substringAfterLast('/')
+}
+
+data class Mp3FolderCandidate(
+ val uri: Uri,
+ /** File basename without extension, from the document URI. */
+ val fileBaseName: String,
+ val id3Title: String?,
+ val id3Artist: String?,
+)
+
+object TrackFileMatching {
+
+ private const val TAG = "LockstepPairing"
+
+ /**
+ * Picks the best-matching local file for a Spotify row using filename and optional ID3
+ * [Mp3FolderCandidate] hints (whichever scores higher per file).
+ */
+ fun bestMatchForFolder(
+ title: String,
+ artist: String,
+ candidates: List,
+ ): Uri? {
+ if (candidates.isEmpty()) return null
+ val trackHint = "$artist $title"
+ val scored =
+ candidates.map { c ->
+ val s = scoreAgainstLocalHints(trackHint, c)
+ Triple(c.uri, s, c)
+ }
+ val best = scored.maxByOrNull { it.second } ?: return null
+ val winner = if (best.second > 0) best.first else null
+ if (winner == null) {
+ val top = scored.sortedByDescending { it.second }.take(5)
+ Log.d(
+ TAG,
+ "match miss spotifyHint='$trackHint' bestScore=${best.second} topCandidates=" +
+ top.joinToString { t ->
+ val c = t.third
+ "${c.fileBaseName}[${t.second}] id3=${c.id3Title ?: "—"}/${c.id3Artist ?: "—"}"
+ },
+ )
+ } else {
+ val c = best.third
+ Log.d(
+ TAG,
+ "match hit score=${best.second} file=${c.fileBaseName} id3=${c.id3Title ?: "—"}/${c.id3Artist ?: "—"} " +
+ "for spotifyHint='$trackHint'",
+ )
+ }
+ return winner
+ }
+
+ /**
+ * Filename-only matching (no ID3). Prefer [bestMatchForFolder] when tags were read on IO.
+ */
+ fun bestMatchForTrack(
+ title: String,
+ artist: String,
+ candidateUris: List,
+ ): Uri? {
+ val candidates =
+ candidateUris.map { uri ->
+ Mp3FolderCandidate(
+ uri,
+ mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
+ null,
+ null,
+ )
+ }
+ return bestMatchForFolder(title, artist, candidates)
+ }
+
+ private fun scoreAgainstLocalHints(
+ trackHint: String,
+ c: Mp3FolderCandidate,
+ ): Int {
+ var best = score(trackHint, c.fileBaseName)
+ val t = c.id3Title?.trim()?.takeIf { it.isNotEmpty() }
+ val a = c.id3Artist?.trim()?.takeIf { it.isNotEmpty() }
+ if (t != null && a != null) {
+ best = maxOf(best, score(trackHint, "$a $t"))
+ }
+ if (t != null) {
+ best = maxOf(best, score(trackHint, t))
+ }
+ if (a != null) {
+ best = maxOf(best, score(trackHint, a))
+ }
+ return best
+ }
+
+ private fun score(
+ trackHint: String,
+ fileBaseName: String,
+ ): Int {
+ val t = normalizeToken(trackHint)
+ val f = normalizeToken(fileBaseName)
+ if (t.isEmpty() || f.isEmpty()) return 0
+ if (f.contains(t) || t.contains(f)) return 100
+ var pts = 0
+ for (word in t.split(' ').filter { it.length > 2 }) {
+ if (f.contains(word)) pts += 10
+ }
+ return pts
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..911c32a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,8 @@
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..503ace7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_layer.xml b/app/src/main/res/drawable/ic_launcher_layer.xml
new file mode 100644
index 0000000..8d0a890
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_layer.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..3ac2612
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #E65100
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 52f623c..e38f5b0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,4 +4,43 @@
Previous track
Play or pause
Next track
+
+ Playback
+ Now playing controls while Lockstep runs
+ Previous
+ Next
+ Play or pause
+ Loading playlist…
+
+ Welcome to Lockstep
+ Lockstep shows playback controls in a notification while you run. Grant notification permission so controls stay visible.
+ Continue and ask for notification permission
+ Sign in with Spotify via the Lockstep web login. When your browser returns to this app, your access token is stored locally.
+ Open Spotify login
+ Account linked — you can continue.
+ Continue
+ Continue without Spotify for now
+
+ Playlists
+ Tap to play (or pair local MP3s)
+
+ Settings
+ More controls will land here in a later milestone.
+
+ Pair local MP3s
+ Choose folder of MP3s
+ Paired with local file
+ Not paired — tap to pick an MP3
+ No MP3 files found in that folder.
+ No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.
+ Tracks appear but have no Spotify id (removed items or sync issue). See LockstepPairing logs.
+ Paired %1$d track(s).
+ Paired %1$d track(s); %2$d still unmatched or unreadable.
+ (removed or unknown track)
+
+ Now playing
+ No track yet
+ —
+ Step frequency
+ --
diff --git a/build.gradle.kts b/build.gradle.kts
index 3cd9b54..64ae344 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,7 @@
plugins {
id("com.android.application") version "8.6.0" apply false
+ id("com.android.library") version "8.6.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
}
diff --git a/jukebox b/jukebox
index f0df38c..e139e81 160000
--- a/jukebox
+++ b/jukebox
@@ -1 +1 @@
-Subproject commit f0df38c98b2204665d6ca523fcf1ccd7d3b67d2a
+Subproject commit e139e810a3103e19aeacbea816f3d00d8328eab5
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 06b8e9c..6af3b6e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,3 +16,5 @@ dependencyResolutionManagement {
rootProject.name = "lockstep-player"
include(":app")
+include(":jukebox")
+project(":jukebox").projectDir = file("jukebox/jukebox")