DetailViewModel.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.detail |
| 2 | ||
| 3 | import androidx.lifecycle.ViewModel | |
| 4 | import androidx.lifecycle.viewModelScope | |
| 5 | import dagger.hilt.android.lifecycle.HiltViewModel | |
| 6 | import dev.stave.data.SettingsStore | |
| 7 | import dev.stave.domain.model.DidlItem | |
| 8 | import dev.stave.domain.model.MetadataField | |
| 9 | import dev.stave.upnp.AlbumInfoLoader | |
| 10 | import dev.stave.upnp.BrowseResult | |
| 11 | import dev.stave.upnp.ContentDirectoryClient | |
| 12 | import dev.stave.upnp.MediaCollector | |
| 13 | import dev.stave.upnp.UpnpServiceHolder | |
| 14 | import kotlinx.coroutines.Job | |
| 15 | import kotlinx.coroutines.delay | |
| 16 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 17 | import kotlinx.coroutines.flow.SharingStarted | |
| 18 | import kotlinx.coroutines.flow.StateFlow | |
| 19 | import kotlinx.coroutines.flow.asStateFlow | |
| 20 | import kotlinx.coroutines.flow.combine | |
| 21 | import kotlinx.coroutines.flow.stateIn | |
| 22 | import kotlinx.coroutines.launch | |
| 23 | import javax.inject.Inject | |
| 24 | ||
| 25 | @HiltViewModel | |
| 26 | class DetailViewModel @Inject constructor( | |
| 27 | private val cd: ContentDirectoryClient, | |
| 28 | private val settings: SettingsStore, | |
| 29 | private val albumInfoLoader: AlbumInfoLoader, | |
| 30 | // Maintainer, 2026-10-29: collector flattens multi-disc albums (disc sub-folders); | |
| 31 | // upnpHolder drives the SSDP rescan between transient-failure retries. | |
| 32 | private val collector: MediaCollector, | |
| 33 | private val upnpHolder: UpnpServiceHolder, | |
| 34 | ) : ViewModel() { | |
| 35 | ||
| 36 | private val _data = MutableStateFlow<DetailData?>(null) | |
| 37 | private val _error = MutableStateFlow<String?>(null) | |
| 38 | ||
| 39 | val state: StateFlow<DetailUiState> = | |
| 40 | combine(_data, _error, settings.enabledFields) { data, error, fields -> | |
| 41 | when { | |
| 42 | error != null -> DetailUiState.Error(error) | |
| 43 | data != null -> DetailUiState.Content( | |
| 44 | tracks = data.tracks, | |
| 45 | albumArtUri = data.albumArtUri, | |
| 46 | albumTitle = data.albumTitle, | |
| 47 | artistName = data.artistName, | |
| 48 | enabledFields = fields, | |
| 49 | ) | |
| 50 | else -> DetailUiState.Loading | |
| 51 | } | |
| 52 | }.stateIn( | |
| 53 | scope = viewModelScope, | |
| 54 | started = SharingStarted.Eagerly, | |
| 55 | initialValue = DetailUiState.Loading | |
| 56 | ) | |
| 57 | ||
| 58 | // Album-info state. Independent of the main loading state so a slow | |
| 59 | // ID3 fetch doesn't block the rest of the screen, and so the user can | |
| 60 | // open/dismiss the modal repeatedly without re-fetching. | |
| 61 | private val _albumInfo = MutableStateFlow<AlbumInfoState>(AlbumInfoState.Idle) | |
| 62 | val albumInfo: StateFlow<AlbumInfoState> = _albumInfo.asStateFlow() | |
| 63 | private var albumInfoJob: Job? = null | |
| 64 | // Maintainer, 2026-10-29: tracked so a Retry (or re-entry) cancels a still-retrying load | |
| 65 | // instead of racing it. | |
| 66 | private var loadJob: Job? = null | |
| 67 | ||
| 68 | fun load(udn: String, containerId: String) { | |
| 69 | _error.value = null | |
| 70 | _data.value = null | |
| 71 | // Reset any in-flight or previously-loaded album info — different album. | |
| 72 | albumInfoJob?.cancel() | |
| 73 | _albumInfo.value = AlbumInfoState.Idle | |
| 74 | loadJob?.cancel() | |
| 75 | loadJob = viewModelScope.launch { | |
| 76 | // Maintainer, 2026-10-29: browse with BrowseViewModel's transient-failure retry | |
| 77 | // (was a single browse, so a cold-start registry race showed a hard error). | |
| 78 | when (val result = browseWithRetry(udn, containerId)) { | |
| 79 | is BrowseResult.Success -> { | |
| 80 | // Maintainer, 2026-10-29: multi-disc albums hold disc sub-folders, not | |
| 81 | // tracks, so Play/Queue album did nothing. When the container has no | |
| 82 | // direct tracks, flatten each non-synthetic child container (browse | |
| 83 | // order) via MediaCollector — the same walk playContainer uses — so the | |
| 84 | // visible list and what Play plays are the same list. | |
| 85 | val directTracks = result.items.filterIsInstance<DidlItem.Track>() | |
| 86 | // One walk of the whole album — exactly what Play album | |
| 87 | // (playContainer) does — so the list can't differ from it. | |
| 88 | val tracks = if (directTracks.isEmpty()) { | |
| 89 | collector.collectTracks(udn, containerId) | |
| 90 | } else { | |
| 91 | directTracks | |
| 92 | } | |
| 93 | val firstArt = tracks.firstNotNullOfOrNull { it.albumArtUri } | |
| 94 | val titleGuess = tracks.firstNotNullOfOrNull { it.album } | |
| 95 | ?: result.items.firstOrNull()?.title | |
| 96 | ?: "Album" | |
| 97 | // albumArtist first because it's the canonical "whose album is this" | |
| 98 | // signal; fall back to per-track artist (classical tracks often vary). | |
| 99 | val artistGuess = tracks.firstNotNullOfOrNull { it.albumArtist ?: it.artist } | |
| 100 | _data.value = DetailData( | |
| 101 | tracks = tracks, | |
| 102 | albumArtUri = firstArt, | |
| 103 | albumTitle = titleGuess, | |
| 104 | artistName = artistGuess, | |
| 105 | ) | |
| 106 | } | |
| 107 | is BrowseResult.Failure -> _error.value = result.message | |
| 108 | } | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | // Maintainer, 2026-10-29: mirrors BrowseViewModel.load's retry — up to 5 attempts for the | |
| 113 | // "not bound / not in registry" race, SSDP rescan before each retry, delay doubling | |
| 114 | // from 200ms. Non-retryable failures and successes return immediately. | |
| 115 | private suspend fun browseWithRetry(udn: String, containerId: String): BrowseResult { | |
| 116 | val retryableMessages = setOf( | |
| 117 | "UPnP service not bound yet", | |
| 118 | "Server no longer in registry", | |
| 119 | ) | |
| 120 | var delayMs = 200L | |
| 121 | var result = cd.browse(udn, containerId) | |
| 122 | repeat(4) { | |
| 123 | val failure = result as? BrowseResult.Failure ?: return result | |
| 124 | if (failure.message !in retryableMessages) return result | |
| 125 | runCatching { upnpHolder.rescan() } | |
| 126 | delay(delayMs) | |
| 127 | delayMs *= 2 | |
| 128 | result = cd.browse(udn, containerId) | |
| 129 | } | |
| 130 | return result | |
| 131 | } | |
| 132 | ||
| 133 | /** Begin an album-info fetch for the album currently in [state]. Idempotent — | |
| 134 | * if we already have a result (success or empty) the existing state is reused; | |
| 135 | * if a fetch is in flight we don't start a second one. */ | |
| 136 | fun loadAlbumInfo() { | |
| 137 | if (_albumInfo.value !is AlbumInfoState.Idle) return | |
| 138 | val firstTrack = _data.value?.tracks?.firstOrNull() ?: run { | |
| 139 | _albumInfo.value = AlbumInfoState.Empty | |
| 140 | return | |
| 141 | } | |
| 142 | _albumInfo.value = AlbumInfoState.Loading | |
| 143 | albumInfoJob = viewModelScope.launch { | |
| 144 | val text = runCatching { albumInfoLoader.load(firstTrack.resourceUrl) } | |
| 145 | .getOrNull() | |
| 146 | _albumInfo.value = if (text.isNullOrBlank()) { | |
| 147 | AlbumInfoState.Empty | |
| 148 | } else { | |
| 149 | AlbumInfoState.Loaded(text) | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | /** Reset album-info state when the user dismisses the modal. Lets a | |
| 155 | * subsequent open re-fetch (rare in practice — comments rarely change | |
| 156 | * while the user is on the same screen — but cheap enough). */ | |
| 157 | fun resetAlbumInfo() { | |
| 158 | albumInfoJob?.cancel() | |
| 159 | _albumInfo.value = AlbumInfoState.Idle | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | data class DetailData( | |
| 164 | val tracks: List<DidlItem.Track>, | |
| 165 | val albumArtUri: String?, | |
| 166 | val albumTitle: String, | |
| 167 | val artistName: String?, | |
| 168 | ) | |
| 169 | ||
| 170 | sealed interface DetailUiState { | |
| 171 | data object Loading : DetailUiState | |
| 172 | data class Error(val message: String) : DetailUiState | |
| 173 | data class Content( | |
| 174 | val tracks: List<DidlItem.Track>, | |
| 175 | val albumArtUri: String?, | |
| 176 | val albumTitle: String, | |
| 177 | val artistName: String?, | |
| 178 | val enabledFields: Set<MetadataField>, | |
| 179 | ) : DetailUiState | |
| 180 | } | |
| 181 | ||
| 182 | /** Album-info loader state. [Idle] before the user opens the modal, | |
| 183 | * [Loading] while we hit the network, [Loaded] with the prose text on | |
| 184 | * success, [Empty] when no source returned anything (no ID3 comment, | |
| 185 | * no sibling .txt). */ | |
| 186 | sealed interface AlbumInfoState { | |
| 187 | data object Idle : AlbumInfoState | |
| 188 | data object Loading : AlbumInfoState | |
| 189 | data class Loaded(val text: String) : AlbumInfoState | |
| 190 | data object Empty : AlbumInfoState | |
| 191 | } |


