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