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

497 lines
18 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
2026-05-24 07:17:51 +02:00
import at.lockstep.player.util.RunDataStorage
import at.lockstep.player.util.RunTrackDataSnapshot
2026-05-14 02:43:49 +02:00
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,
)
2026-05-24 07:17:51 +02:00
val collectRunData: StateFlow<Boolean> =
prefs.collectRunData.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
2026-05-15 09:03:20 +02:00
fun setAnnotationMode(enabled: Boolean) {
viewModelScope.launch {
prefs.setAnnotationMode(enabled)
}
}
2026-05-24 07:17:51 +02:00
fun setCollectRunData(enabled: Boolean) {
viewModelScope.launch {
prefs.setCollectRunData(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-24 07:17:51 +02:00
/**
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
* Skips when [samples] is empty or the track has no paired local URI.
*/
fun persistRunData(
playlistId: String,
playlistDisplayName: String,
runSessionFolder: String,
event: TrackBoundaryEvent,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile(
context = getApplication(),
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
metaContentUri = meta,
snapshot = snapshot,
)
}
}
/** Flush in-progress run data when leaving Now Playing before a track boundary fires. */
fun persistRunDataForCurrentTrack(
playlistId: String,
playlistDisplayName: String,
runSessionFolder: String,
trackId: String,
queueIndex: Int,
snapshot: RunTrackDataSnapshot,
) {
if (snapshot.isEmpty()) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile(
context = getApplication(),
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex,
metaContentUri = meta,
snapshot = snapshot,
)
}
}
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
}
}