2026-05-14 02:43:49 +02:00
|
|
|
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
|
2026-05-15 09:03:20 +02:00
|
|
|
import at.lockstep.player.playback.TrackBoundaryEvent
|
|
|
|
|
import at.lockstep.player.util.BeatAnnotationStorage
|
2026-05-14 02:43:49 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-15 09:03:20 +02:00
|
|
|
val annotationMode: StateFlow<Boolean> =
|
|
|
|
|
prefs.annotationMode.stateIn(
|
|
|
|
|
viewModelScope,
|
|
|
|
|
SharingStarted.Eagerly,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fun setAnnotationMode(enabled: Boolean) {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setAnnotationMode(enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
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
|
|
|
|
|
|
2026-05-15 09:03:20 +02:00
|
|
|
suspend fun getPlaylistDisplayName(playlistId: String): String =
|
|
|
|
|
withContext(Dispatchers.IO) {
|
|
|
|
|
app.playlistRepository.getPlaylists()
|
|
|
|
|
.find { it.id == playlistId }
|
|
|
|
|
?.name
|
|
|
|
|
?.trim()
|
|
|
|
|
.orEmpty()
|
|
|
|
|
.ifBlank { "playlist" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described
|
|
|
|
|
* by [event]. Skips when [beatTimesMs] is empty.
|
|
|
|
|
*/
|
|
|
|
|
fun persistBeatAnnotation(
|
|
|
|
|
playlistId: String,
|
|
|
|
|
playlistDisplayName: String,
|
|
|
|
|
event: TrackBoundaryEvent,
|
|
|
|
|
beatTimesMs: List<Long>,
|
|
|
|
|
) {
|
|
|
|
|
if (beatTimesMs.isEmpty()) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
|
|
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
|
|
|
|
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
|
|
|
|
val contentId = docId.ifBlank { event.trackId }
|
|
|
|
|
BeatAnnotationStorage.writeAnnotationsFile(
|
|
|
|
|
context = getApplication(),
|
|
|
|
|
playlistDisplayName = playlistDisplayName,
|
|
|
|
|
trackQueueIndex0Based = event.queueIndex,
|
|
|
|
|
contentId = contentId,
|
|
|
|
|
title = event.title,
|
|
|
|
|
artist = event.artist,
|
|
|
|
|
beatTimesMs = beatTimesMs,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
suspend fun syncJukeboxIfToken(): String? {
|
|
|
|
|
val token = spotifyAccessToken.value
|
|
|
|
|
if (token.isNullOrBlank()) {
|
|
|
|
|
return "Not signed in"
|
|
|
|
|
}
|
|
|
|
|
app.setSpotifyAccessTokenForApi(token)
|
|
|
|
|
return withContext(Dispatchers.IO) {
|
|
|
|
|
try {
|
2026-05-14 03:03:17 +02:00
|
|
|
app.playlistRepository.syncDelta(false)
|
2026-05-14 02:43:49 +02:00
|
|
|
null
|
|
|
|
|
} catch (e: LockstepApiException) {
|
|
|
|
|
e.message ?: "Sync failed"
|
|
|
|
|
} catch (e: IOException) {
|
|
|
|
|
e.message ?: "Sync failed"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 03:03:17 +02:00
|
|
|
suspend fun syncPlaylistDetailForLibraryOpen(playlistId: String): String? {
|
|
|
|
|
val token = spotifyAccessToken.value
|
|
|
|
|
if (token.isNullOrBlank()) {
|
|
|
|
|
return "Not signed in"
|
|
|
|
|
}
|
|
|
|
|
app.setSpotifyAccessTokenForApi(token)
|
|
|
|
|
return withContext(Dispatchers.IO) {
|
|
|
|
|
if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) {
|
|
|
|
|
return@withContext null
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
app.playlistRepository.syncPlaylistDetail(playlistId)
|
|
|
|
|
null
|
|
|
|
|
} catch (e: LockstepApiException) {
|
|
|
|
|
e.message ?: "Load failed"
|
|
|
|
|
} catch (e: IOException) {
|
|
|
|
|
e.message ?: "Load failed"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
fun completeOnboarding() {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setOnboardingComplete(true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun saveSpotifyAccessToken(token: String?) {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setSpotifyAccessToken(token)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 03:03:17 +02:00
|
|
|
/** Clears the Spotify token and shows the first-run flow again so you can sign in with a fresh token. */
|
|
|
|
|
fun logoutSpotifyAndRestartOnboarding() {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setSpotifyAccessToken(null)
|
|
|
|
|
prefs.setOnboardingComplete(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|