Files
lockstep-player/app/src/main/java/at/lockstep/player/LockstepViewModel.kt

428 lines
16 KiB
Kotlin
Raw Normal View History

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
2026-05-24 06:47:10 +02:00
import at.lockstep.player.util.MediaStoreMp3Scanner
2026-05-14 02:43:49 +02:00
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 {
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"
}
}
}
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)
}
}
/** 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)
2026-05-24 06:47:10 +02:00
logJukeboxTracks(tracks)
2026-05-14 02:43:49 +02:00
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)
}
2026-05-24 06:47:10 +02:00
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
2026-05-14 02:43:49 +02:00
2026-05-24 06:47:10 +02:00
/** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */
fun pairPlaylistFromDeviceAudio(
playlistId: String,
onFinished: (FolderPairingResult) -> Unit,
) {
viewModelScope.launch {
Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId")
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val candidates = MediaStoreMp3Scanner.listMp3Candidates(context)
Pair(candidates.size, candidates.toMutableList())
}
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
2026-05-14 02:43:49 +02:00
2026-05-24 06:47:10 +02:00
private fun logJukeboxTracks(tracks: List<TrackRow>) {
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 ->
2026-05-14 02:43:49 +02:00
Log.d(
TAG,
2026-05-24 06:47:10 +02:00
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
"artist='${row.artistName}' durationMs=${row.durationMs}",
2026-05-14 02:43:49 +02:00
)
2026-05-24 06:47:10 +02:00
}
if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
}
}
2026-05-14 02:43:49 +02:00
2026-05-24 06:47:10 +02:00
private suspend fun matchPlaylistTracks(
playlistId: String,
tracks: List<TrackRow>,
pool: MutableList<Mp3FolderCandidate>,
mp3Total: Int,
): FolderPairingResult {
if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
return FolderPairingResult(
paired = 0,
failed = 0,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0,
2026-05-14 02:43:49 +02:00
)
}
2026-05-24 06:47:10 +02:00
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,
"playlist pairing done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
)
return FolderPairingResult(
paired = paired,
failed = failed,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
)
2026-05-14 02:43:49 +02:00
}
}