feat: sync Playlists, wip: pair songs
This commit is contained in:
14
app/src/main/java/at/lockstep/player/FolderPairingResult.kt
Normal file
14
app/src/main/java/at/lockstep/player/FolderPairingResult.kt
Normal file
@@ -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,
|
||||
)
|
||||
62
app/src/main/java/at/lockstep/player/LockstepApplication.kt
Normal file
62
app/src/main/java/at/lockstep/player/LockstepApplication.kt
Normal file
@@ -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<String?>(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
|
||||
}
|
||||
}
|
||||
319
app/src/main/java/at/lockstep/player/LockstepViewModel.kt
Normal file
319
app/src/main/java/at/lockstep/player/LockstepViewModel.kt
Normal file
@@ -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<String, Mutex>()
|
||||
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<Boolean> =
|
||||
prefs.onboardingComplete.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
|
||||
val spotifyAccessToken: StateFlow<String?> =
|
||||
prefs.spotifyAccessToken.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
|
||||
private val context get() = getApplication<Application>()
|
||||
|
||||
/**
|
||||
* 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<TrackRow> {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(LockstepViewModel::class.java)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return LockstepViewModel(application) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
@@ -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<Intent> { 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
app/src/main/java/at/lockstep/player/auth/SpotifyLogin.kt
Normal file
44
app/src/main/java/at/lockstep/player/auth/SpotifyLogin.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Preferences> by preferencesDataStore(name = "lockstep_settings")
|
||||
|
||||
class UserPreferencesRepository(
|
||||
context: Context,
|
||||
) {
|
||||
private val dataStore = context.applicationContext.settingsDataStore
|
||||
|
||||
val onboardingComplete: Flow<Boolean> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[KEY_ONBOARDING_COMPLETE] == true
|
||||
}
|
||||
|
||||
val spotifyAccessToken: Flow<String?> =
|
||||
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")
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt
Normal file
37
app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/at/lockstep/player/data/db/PairingDao.kt
Normal file
29
app/src/main/java/at/lockstep/player/data/db/PairingDao.kt
Normal file
@@ -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<List<TrackPairingEntity>>
|
||||
|
||||
@Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
|
||||
suspend fun listForPlaylist(playlistId: String): List<TrackPairingEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(row: TrackPairingEntity)
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
375
app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
Normal file
375
app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
Normal file
@@ -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<PlaybackUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var queue: List<TrackQueueItem> = 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"
|
||||
}
|
||||
}
|
||||
128
app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
Normal file
128
app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt
Normal file
@@ -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<PlaybackService?>(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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
109
app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
Normal file
109
app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt
Normal file
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
Normal file
13
app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
217
app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
Normal file
217
app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt
Normal file
@@ -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<TrackRow?>(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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/at/lockstep/player/ui/theme/Color.kt
Normal file
11
app/src/main/java/at/lockstep/player/ui/theme/Color.kt
Normal file
@@ -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)
|
||||
57
app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
Normal file
57
app/src/main/java/at/lockstep/player/ui/theme/Theme.kt
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
Normal file
118
app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
Normal file
@@ -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<Uri> {
|
||||
|
||||
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<Uri>()
|
||||
|
||||
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",
|
||||
|
||||
)
|
||||
|
||||
@@ -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<String?, String?> {
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
Normal file
124
app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
Normal file
@@ -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<Mp3FolderCandidate>,
|
||||
): 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>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user