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

DetailViewModel.kt

package dev.stave.ui.detail

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.stave.data.SettingsStore
import dev.stave.domain.model.DidlItem
import dev.stave.domain.model.MetadataField
import dev.stave.upnp.AlbumInfoLoader
import dev.stave.upnp.BrowseResult
import dev.stave.upnp.ContentDirectoryClient
import dev.stave.upnp.MediaCollector
import dev.stave.upnp.UpnpServiceHolder
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val cd: ContentDirectoryClient,
    private val settings: SettingsStore,
    private val albumInfoLoader: AlbumInfoLoader,
    // Maintainer, 2026-10-29: collector flattens multi-disc albums (disc sub-folders);
    // upnpHolder drives the SSDP rescan between transient-failure retries.
    private val collector: MediaCollector,
    private val upnpHolder: UpnpServiceHolder,
) : ViewModel() {

    private val _data = MutableStateFlow<DetailData?>(null)
    private val _error = MutableStateFlow<String?>(null)

    val state: StateFlow<DetailUiState> =
        combine(_data, _error, settings.enabledFields) { data, error, fields ->
            when {
                error != null -> DetailUiState.Error(error)
                data != null -> DetailUiState.Content(
                    tracks = data.tracks,
                    albumArtUri = data.albumArtUri,
                    albumTitle = data.albumTitle,
                    artistName = data.artistName,
                    enabledFields = fields,
                )
                else -> DetailUiState.Loading
            }
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.Eagerly,
            initialValue = DetailUiState.Loading
        )

    // Album-info state. Independent of the main loading state so a slow
    // ID3 fetch doesn't block the rest of the screen, and so the user can
    // open/dismiss the modal repeatedly without re-fetching.
    private val _albumInfo = MutableStateFlow<AlbumInfoState>(AlbumInfoState.Idle)
    val albumInfo: StateFlow<AlbumInfoState> = _albumInfo.asStateFlow()
    private var albumInfoJob: Job? = null
    // Maintainer, 2026-10-29: tracked so a Retry (or re-entry) cancels a still-retrying load
    // instead of racing it.
    private var loadJob: Job? = null

    fun load(udn: String, containerId: String) {
        _error.value = null
        _data.value = null
        // Reset any in-flight or previously-loaded album info — different album.
        albumInfoJob?.cancel()
        _albumInfo.value = AlbumInfoState.Idle
        loadJob?.cancel()
        loadJob = viewModelScope.launch {
            // Maintainer, 2026-10-29: browse with BrowseViewModel's transient-failure retry
            // (was a single browse, so a cold-start registry race showed a hard error).
            when (val result = browseWithRetry(udn, containerId)) {
                is BrowseResult.Success -> {
                    // Maintainer, 2026-10-29: multi-disc albums hold disc sub-folders, not
                    // tracks, so Play/Queue album did nothing. When the container has no
                    // direct tracks, flatten each non-synthetic child container (browse
                    // order) via MediaCollector — the same walk playContainer uses — so the
                    // visible list and what Play plays are the same list.
                    val directTracks = result.items.filterIsInstance<DidlItem.Track>()
                    // One walk of the whole album — exactly what Play album
                    // (playContainer) does — so the list can't differ from it.
                    val tracks = if (directTracks.isEmpty()) {
                        collector.collectTracks(udn, containerId)
                    } else {
                        directTracks
                    }
                    val firstArt = tracks.firstNotNullOfOrNull { it.albumArtUri }
                    val titleGuess = tracks.firstNotNullOfOrNull { it.album }
                        ?: result.items.firstOrNull()?.title
                        ?: "Album"
                    // albumArtist first because it's the canonical "whose album is this"
                    // signal; fall back to per-track artist (classical tracks often vary).
                    val artistGuess = tracks.firstNotNullOfOrNull { it.albumArtist ?: it.artist }
                    _data.value = DetailData(
                        tracks = tracks,
                        albumArtUri = firstArt,
                        albumTitle = titleGuess,
                        artistName = artistGuess,
                    )
                }
                is BrowseResult.Failure -> _error.value = result.message
            }
        }
    }

    // Maintainer, 2026-10-29: mirrors BrowseViewModel.load's retry — up to 5 attempts for the
    // "not bound / not in registry" race, SSDP rescan before each retry, delay doubling
    // from 200ms. Non-retryable failures and successes return immediately.
    private suspend fun browseWithRetry(udn: String, containerId: String): BrowseResult {
        val retryableMessages = setOf(
            "UPnP service not bound yet",
            "Server no longer in registry",
        )
        var delayMs = 200L
        var result = cd.browse(udn, containerId)
        repeat(4) {
            val failure = result as? BrowseResult.Failure ?: return result
            if (failure.message !in retryableMessages) return result
            runCatching { upnpHolder.rescan() }
            delay(delayMs)
            delayMs *= 2
            result = cd.browse(udn, containerId)
        }
        return result
    }

    /** Begin an album-info fetch for the album currently in [state]. Idempotent —
     *  if we already have a result (success or empty) the existing state is reused;
     *  if a fetch is in flight we don't start a second one. */
    fun loadAlbumInfo() {
        if (_albumInfo.value !is AlbumInfoState.Idle) return
        val firstTrack = _data.value?.tracks?.firstOrNull() ?: run {
            _albumInfo.value = AlbumInfoState.Empty
            return
        }
        _albumInfo.value = AlbumInfoState.Loading
        albumInfoJob = viewModelScope.launch {
            val text = runCatching { albumInfoLoader.load(firstTrack.resourceUrl) }
                .getOrNull()
            _albumInfo.value = if (text.isNullOrBlank()) {
                AlbumInfoState.Empty
            } else {
                AlbumInfoState.Loaded(text)
            }
        }
    }

    /** Reset album-info state when the user dismisses the modal. Lets a
     *  subsequent open re-fetch (rare in practice — comments rarely change
     *  while the user is on the same screen — but cheap enough). */
    fun resetAlbumInfo() {
        albumInfoJob?.cancel()
        _albumInfo.value = AlbumInfoState.Idle
    }
}

data class DetailData(
    val tracks: List<DidlItem.Track>,
    val albumArtUri: String?,
    val albumTitle: String,
    val artistName: String?,
)

sealed interface DetailUiState {
    data object Loading : DetailUiState
    data class Error(val message: String) : DetailUiState
    data class Content(
        val tracks: List<DidlItem.Track>,
        val albumArtUri: String?,
        val albumTitle: String,
        val artistName: String?,
        val enabledFields: Set<MetadataField>,
    ) : DetailUiState
}

/** Album-info loader state. [Idle] before the user opens the modal,
 *  [Loading] while we hit the network, [Loaded] with the prose text on
 *  success, [Empty] when no source returned anything (no ID3 comment,
 *  no sibling .txt). */
sealed interface AlbumInfoState {
    data object Idle : AlbumInfoState
    data object Loading : AlbumInfoState
    data class Loaded(val text: String) : AlbumInfoState
    data object Empty : AlbumInfoState
}