DetailViewModel.kt
191 lines · 8.3 KB
Changed in: 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 | } |


