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

DetailViewModel.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.detail
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dagger.hilt.android.lifecycle.HiltViewModel
6import dev.stave.data.SettingsStore
7import dev.stave.domain.model.DidlItem
8import dev.stave.domain.model.MetadataField
9import dev.stave.upnp.AlbumInfoLoader
10import dev.stave.upnp.BrowseResult
11import dev.stave.upnp.ContentDirectoryClient
12import dev.stave.upnp.MediaCollector
13import dev.stave.upnp.UpnpServiceHolder
14import kotlinx.coroutines.Job
15import kotlinx.coroutines.delay
16import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.SharingStarted
18import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.asStateFlow
20import kotlinx.coroutines.flow.combine
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.launch
23import javax.inject.Inject
24
25@HiltViewModel
26class 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
163data class DetailData(
164 val tracks: List<DidlItem.Track>,
165 val albumArtUri: String?,
166 val albumTitle: String,
167 val artistName: String?,
168)
169
170sealed 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). */
186sealed 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}