ServersViewModel.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.servers |
| 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.MediaServer | |
| 8 | import dev.stave.upnp.ContentDirectoryClient | |
| 9 | import dev.stave.upnp.MinimServerControl | |
| 10 | import dev.stave.upnp.UpnpServiceHolder | |
| 11 | import kotlinx.coroutines.Job | |
| 12 | import kotlinx.coroutines.coroutineScope | |
| 13 | import kotlinx.coroutines.delay | |
| 14 | import kotlinx.coroutines.flow.Flow | |
| 15 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 16 | import kotlinx.coroutines.flow.StateFlow | |
| 17 | import kotlinx.coroutines.flow.asStateFlow | |
| 18 | import kotlinx.coroutines.flow.firstOrNull | |
| 19 | import kotlinx.coroutines.launch | |
| 20 | import kotlinx.coroutines.withTimeoutOrNull | |
| 21 | import javax.inject.Inject | |
| 22 | ||
| 23 | /** Per-server lifecycle of a MinimServer rescan request. The screen reads this | |
| 24 | * flow to decide whether each server row shows its normal Rescan affordance, | |
| 25 | * an in-flight spinner, a "waiting for server" message, or a transient | |
| 26 | * success/failure indicator before snapping back to Idle. */ | |
| 27 | sealed class RescanState { | |
| 28 | object Idle : RescanState() | |
| 29 | /** POST in flight — we've sent `rescan` but the server hasn't yet | |
| 30 | * acknowledged with 200 OK. */ | |
| 31 | data class Requesting(val udn: String, val friendlyName: String) : RescanState() | |
| 32 | /** Receiver accepted; we're now watching the UPnP registry for it to | |
| 33 | * disappear (rescan started) and reappear (rescan finished). */ | |
| 34 | data class WaitingForServer( | |
| 35 | val udn: String, | |
| 36 | val friendlyName: String, | |
| 37 | val seenDisappear: Boolean, | |
| 38 | ) : RescanState() | |
| 39 | /** Server came back. Held briefly so the row can flash a check before | |
| 40 | * returning to Idle. */ | |
| 41 | data class Completed(val udn: String, val friendlyName: String) : RescanState() | |
| 42 | data class Failed(val udn: String, val friendlyName: String, val message: String) : RescanState() | |
| 43 | } | |
| 44 | ||
| 45 | @HiltViewModel | |
| 46 | class ServersViewModel @Inject constructor( | |
| 47 | private val holder: UpnpServiceHolder, | |
| 48 | private val settings: SettingsStore, | |
| 49 | private val cd: ContentDirectoryClient, | |
| 50 | private val minim: MinimServerControl, | |
| 51 | ) : ViewModel() { | |
| 52 | val servers: StateFlow<List<MediaServer>> = holder.servers | |
| 53 | val preferredServerUdn: Flow<String?> = settings.preferredServerUdn | |
| 54 | ||
| 55 | private val _rescanState = MutableStateFlow<RescanState>(RescanState.Idle) | |
| 56 | val rescanState: StateFlow<RescanState> = _rescanState.asStateFlow() | |
| 57 | ||
| 58 | /** Tracks the in-flight rescan coroutine so a second tap on the same row | |
| 59 | * while one is still running is a no-op rather than starting a parallel | |
| 60 | * request. Also lets us cancel if the user navigates away. */ | |
| 61 | private var rescanJob: Job? = null | |
| 62 | ||
| 63 | /** Called when the user taps a server row — persists the choice so next launch can | |
| 64 | * auto-navigate to Browse once the server reappears on the network. */ | |
| 65 | fun rememberChoice(udn: String) { | |
| 66 | viewModelScope.launch { settings.setPreferredServer(udn) } | |
| 67 | } | |
| 68 | ||
| 69 | /** Rescan the network for UPnP servers without touching the user's current preference. | |
| 70 | * Used when they're already on the picker and want to refresh. */ | |
| 71 | fun rescan() { | |
| 72 | holder.rescan() | |
| 73 | } | |
| 74 | ||
| 75 | /** Clear the persisted preferred server AND rescan. Used from Settings → "Choose | |
| 76 | * server" when the user wants to switch libraries — this sends them through the | |
| 77 | * picker again, and next app launch will hit WELCOME until they pick. Also drops | |
| 78 | * any cached random-library list since that list is server-specific. */ | |
| 79 | fun forgetAndRescan() { | |
| 80 | viewModelScope.launch { settings.setPreferredServer(null) } | |
| 81 | cd.invalidateRandomCache() | |
| 82 | holder.rescan() | |
| 83 | } | |
| 84 | ||
| 85 | // Maintainer, 2026-10-29: Settings → Choose server now uses this instead | |
| 86 | // of forgetAndRescan — opening the picker no longer deletes the saved | |
| 87 | // server; the preference changes only when the user picks one | |
| 88 | // (rememberChoice). Random cache dropped as before. | |
| 89 | fun rescanForPick() { | |
| 90 | cd.invalidateRandomCache() | |
| 91 | holder.rescan() | |
| 92 | } | |
| 93 | ||
| 94 | /** Trigger a library rescan on the MinimServer at [udn]. | |
| 95 | * | |
| 96 | * Flow of states the UI observes via [rescanState]: | |
| 97 | * 1. Idle (initial; also after Completed/Failed snap back) | |
| 98 | * 2. Requesting(udn) — POST in flight | |
| 99 | * 3. WaitingForServer(udn, seenDisappear=false) — server hasn't yet | |
| 100 | * dropped off the registry. Some MinimServer versions take a few | |
| 101 | * seconds to actually go silent after acking the rescan command. | |
| 102 | * 4. WaitingForServer(udn, seenDisappear=true) — server is gone, | |
| 103 | * indexing in progress. | |
| 104 | * 5. Completed(udn) — server came back. Held COMPLETED_HOLD_MS so the UI can flash | |
| 105 | * a check. Library caches are invalidated at this point. | |
| 106 | * 6. Failed(udn, message) on any error. Held ~4s. | |
| 107 | * | |
| 108 | * No-ops if a rescan is already running (any non-Idle state). */ | |
| 109 | fun rescanLibrary(udn: String) { | |
| 110 | if (_rescanState.value !is RescanState.Idle) return | |
| 111 | // Snapshot the friendly name now — once the rescan starts, the | |
| 112 | // server will disappear from `holder.servers` and we'd lose the | |
| 113 | // ability to label the placeholder row. Fall back to the UDN if | |
| 114 | // for some reason the name isn't currently known (shouldn't | |
| 115 | // happen since the row only renders Rescan if the server is in | |
| 116 | // the list at tap time). | |
| 117 | val friendlyName = holder.servers.value.firstOrNull { it.udn == udn }?.friendlyName ?: udn | |
| 118 | rescanJob?.cancel() | |
| 119 | rescanJob = viewModelScope.launch { | |
| 120 | _rescanState.value = RescanState.Requesting(udn, friendlyName) | |
| 121 | when (val r = minim.rescanLibrary(udn)) { | |
| 122 | is MinimServerControl.Result.Failure -> { | |
| 123 | _rescanState.value = RescanState.Failed(udn, friendlyName, r.message) | |
| 124 | delay(FAILURE_HOLD_MS) | |
| 125 | if (_rescanState.value is RescanState.Failed) { | |
| 126 | _rescanState.value = RescanState.Idle | |
| 127 | } | |
| 128 | } | |
| 129 | MinimServerControl.Result.Success -> { | |
| 130 | awaitServerCycle(udn, friendlyName) | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | /** Wait for the rescanning server to (a) leave the registry and (b) come | |
| 137 | * back. Both transitions are observed via the holder's flow. We also | |
| 138 | * fire periodic SSDP M-SEARCHes during the waiting phase to expedite | |
| 139 | * re-discovery — MinimServer announces itself via NOTIFY on startup but | |
| 140 | * the search shortens the worst-case delay from ~30s to a few seconds. */ | |
| 141 | private suspend fun awaitServerCycle(udn: String, friendlyName: String) { | |
| 142 | _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = false) | |
| 143 | ||
| 144 | // Phase 1: wait for the server to disappear. Generous timeout — most | |
| 145 | // MinimServer rescans drop the registry presence within 10–15s. | |
| 146 | val disappeared = withTimeoutOrNull(DISAPPEAR_TIMEOUT_MS) { | |
| 147 | holder.servers.firstOrNull { list -> | |
| 148 | list.none { it.udn == udn } | |
| 149 | } | |
| 150 | } | |
| 151 | if (disappeared == null) { | |
| 152 | // The server never dropped off — likely the rescan didn't | |
| 153 | // actually trigger, or this server isn't a real MinimServer. | |
| 154 | // Surface as a soft failure rather than wedging the UI. | |
| 155 | _rescanState.value = RescanState.Failed( | |
| 156 | udn, | |
| 157 | friendlyName, | |
| 158 | "Server didn't restart — rescan may not have happened", | |
| 159 | ) | |
| 160 | delay(FAILURE_HOLD_MS) | |
| 161 | if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle | |
| 162 | return | |
| 163 | } | |
| 164 | _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = true) | |
| 165 | ||
| 166 | // Phase 2: wait for the server to come back. Library rescans can take | |
| 167 | // minutes-to-hours for large collections; cap at 10 minutes so the | |
| 168 | // UI doesn't sit there forever if the server failed to come back at | |
| 169 | // all. Fire periodic SSDP searches to nudge re-discovery — without | |
| 170 | // this, the registry waits for the server's own NOTIFY which can be | |
| 171 | // slow. coroutineScope gives `launch` a receiver and confines the | |
| 172 | // SSDP-nudge job's lifetime to this phase; cancelling it on exit | |
| 173 | // lets the scope complete cleanly. | |
| 174 | // | |
| 175 | // CRITICAL: the SSDP nudge keeps running through the Completed hold | |
| 176 | // too (not just until first detection). jUPnP's grip on a | |
| 177 | // freshly-restarted MinimServer is fragile — the first SSDP search | |
| 178 | // response gets the device into the registry, but if no further | |
| 179 | // SSDP traffic arrives before the device's max-age ticks down, the | |
| 180 | // registry drops it. The user then sees the placeholder disappear | |
| 181 | // followed by an empty list until they manually hit refresh. | |
| 182 | // Holding the nudge keeps a fresh M-SEARCH arriving every few | |
| 183 | // seconds; the server responds, jUPnP's registry resets its | |
| 184 | // expiration timer, and the device stays put until its own NOTIFY | |
| 185 | // heartbeats take over. | |
| 186 | val reappeared = coroutineScope { | |
| 187 | val ssdpJob = launch { | |
| 188 | while (true) { | |
| 189 | // Nudge first, then delay — without this, the first | |
| 190 | // nudge wouldn't fire until SSDP_NUDGE_INTERVAL_MS | |
| 191 | // after the loop launches, leaving a quiet window | |
| 192 | // where jUPnP can drop the device before any fresh | |
| 193 | // SSDP traffic arrives. | |
| 194 | holder.rescan() | |
| 195 | delay(SSDP_NUDGE_INTERVAL_MS) | |
| 196 | } | |
| 197 | } | |
| 198 | try { | |
| 199 | val r = withTimeoutOrNull(REAPPEAR_TIMEOUT_MS) { | |
| 200 | holder.servers.firstOrNull { list -> | |
| 201 | list.any { it.udn == udn } | |
| 202 | } | |
| 203 | } | |
| 204 | if (r == null) return@coroutineScope null | |
| 205 | // Library has changed — invalidate the random-album | |
| 206 | // cache so the next Random pick reflects the new | |
| 207 | // state. Browse caches are re-fetched lazily on the | |
| 208 | // next browse, so no further invalidation is needed. | |
| 209 | cd.invalidateRandomCache() | |
| 210 | _rescanState.value = RescanState.Completed(udn, friendlyName) | |
| 211 | // Hold Completed (UI shows the check) while the SSDP | |
| 212 | // nudge above keeps the registry warm. | |
| 213 | delay(COMPLETED_HOLD_MS) | |
| 214 | if (_rescanState.value is RescanState.Completed) _rescanState.value = RescanState.Idle | |
| 215 | r | |
| 216 | } finally { | |
| 217 | ssdpJob.cancel() | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | if (reappeared == null) { | |
| 222 | _rescanState.value = RescanState.Failed( | |
| 223 | udn, | |
| 224 | friendlyName, | |
| 225 | "Server didn't come back after rescan (try again from the picker)", | |
| 226 | ) | |
| 227 | delay(FAILURE_HOLD_MS) | |
| 228 | if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle | |
| 229 | return | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | companion object { | |
| 234 | // Hold transient states briefly so the UI can flash success/failure | |
| 235 | // before the row returns to its normal Rescan affordance. | |
| 236 | private const val COMPLETED_HOLD_MS = 4_000L | |
| 237 | private const val FAILURE_HOLD_MS = 4_000L | |
| 238 | // Phase timeouts. Disappear is short (rescan should restart the | |
| 239 | // server fast); reappear is long (indexing a big library is slow). | |
| 240 | private const val DISAPPEAR_TIMEOUT_MS = 30_000L | |
| 241 | private const val REAPPEAR_TIMEOUT_MS = 10 * 60 * 1000L | |
| 242 | // How often to fire SSDP M-SEARCH while waiting for the server to | |
| 243 | // come back. Tight enough that at least one nudge fires during | |
| 244 | // the Completed hold (4s hold > 3s interval) so jUPnP doesn't | |
| 245 | // drop the freshly-rediscovered device before its first NOTIFY | |
| 246 | // heartbeat arrives. | |
| 247 | private const val SSDP_NUDGE_INTERVAL_MS = 3_000L | |
| 248 | } | |
| 249 | } |


