EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

AlbumInfoViewModel.kt

package dev.stave.ui.albuminfo

import androidx.lifecycle.ViewModel
import dev.stave.domain.model.DidlItem
import dev.stave.ui.safeLaunch
import dev.stave.upnp.AlbumInfoLoader
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/** Album-notes state. [Idle] before the user asks, [Loading] while we look,
 *  [Loaded] with the prose, [Empty] when no source had anything (no comment
 *  tag, no sibling .txt). Same shape as Android's DetailViewModel.AlbumInfoState. */
sealed interface AlbumInfoState {
    data object Idle : AlbumInfoState
    data object Loading : AlbumInfoState
    data class Loaded(val text: String) : AlbumInfoState
    data object Empty : AlbumInfoState
}

/**
 * Drives the "Album notes" dialog (port of Android's DetailViewModel
 * loadAlbumInfo / resetAlbumInfo). Every path that sets [AlbumInfoState.Loading]
 * ends in Loaded or Empty, including on an unexpected throw, so the dialog can't
 * spin forever.
 */
class AlbumInfoViewModel(
    private val loader: AlbumInfoLoader,
) : ViewModel() {

    private val _state = MutableStateFlow<AlbumInfoState>(AlbumInfoState.Idle)
    val state: StateFlow<AlbumInfoState> = _state.asStateFlow()

    private var job: Job? = null

    /** Look up notes for the album [track] belongs to. Replaces any lookup in
     *  flight (e.g. the user reopened the dialog after the track changed). */
    fun load(track: DidlItem.Track) {
        job?.cancel()
        _state.value = AlbumInfoState.Loading
        job = safeLaunch {
            val text = try {
                loader.load(track.resourceUrl)
            } catch (c: CancellationException) {
                throw c
            } catch (e: Exception) {
                System.err.println("[Earhorn] album notes lookup failed: ${e.javaClass.simpleName}: ${e.message}")
                null
            }
            _state.value = if (text.isNullOrBlank()) AlbumInfoState.Empty else AlbumInfoState.Loaded(text)
        }
    }

    /** Back to Idle when the dialog closes; cancels a lookup in flight. */
    fun reset() {
        job?.cancel()
        job = null
        _state.value = AlbumInfoState.Idle
    }
}