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
}