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

ServersViewModel.kt

191 lines · 8.5 KB

Changed in: 0.5.0

1package dev.stave.ui.servers
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dev.stave.domain.model.MediaServer
6import dev.stave.upnp.ContentDirectoryClient
7import dev.stave.upnp.MinimServerControl
8import dev.stave.upnp.UpnpServiceHolder
9import kotlinx.coroutines.Job
10import kotlinx.coroutines.coroutineScope
11import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.first
17import kotlinx.coroutines.flow.stateIn
18import kotlinx.coroutines.flow.transformLatest
19import kotlinx.coroutines.launch
20import kotlinx.coroutines.withTimeoutOrNull
21
22/** Per-server lifecycle of a MinimServer library rescan. Ported from Earhorn
23 * Android so both products show the same states and copy. */
24sealed class RescanState {
25 data object Idle : RescanState()
26 /** POST in flight — `rescan` sent, not yet acknowledged. */
27 data class Requesting(val udn: String, val friendlyName: String) : RescanState()
28 /** Accepted; watching the registry for the server to leave (rescan
29 * started) and come back (rescan finished). */
30 data class WaitingForServer(
31 val udn: String,
32 val friendlyName: String,
33 val seenDisappear: Boolean,
34 ) : RescanState()
35 /** Server came back. Held briefly so the UI can show success. */
36 data class Completed(val udn: String, val friendlyName: String) : RescanState()
37 data class Failed(val udn: String, val friendlyName: String, val message: String) : RescanState()
38
39 /** True while a rescan is actually running (not just showing a result). */
40 val inFlight: Boolean get() = this is Requesting || this is WaitingForServer
41
42 /** Plain-language line for a status area / toast. Same copy as Android. */
43 val description: String?
44 get() = when (this) {
45 Idle -> null
46 is Requesting -> "Asking $friendlyName to rescan its library…"
47 is WaitingForServer ->
48 if (seenDisappear) "Rescanning $friendlyName — server restarting…"
49 else "Waiting for $friendlyName to start rescanning…"
50 is Completed -> "Rescan complete — $friendlyName library refreshed"
51 is Failed -> "Rescan of $friendlyName failed: $message"
52 }
53}
54
55/**
56 * Exposes the live list of discovered MediaServers, an SSDP rescan trigger, and
57 * (new, parity with Android) a MinimServer **library** rescan with observable
58 * progress. Thin wrapper around [UpnpServiceHolder] so the UI doesn't import
59 * UPnP types.
60 */
61class ServersViewModel(
62 private val holder: UpnpServiceHolder,
63 private val cd: ContentDirectoryClient,
64 private val minim: MinimServerControl,
65) : ViewModel() {
66
67 val servers: StateFlow<List<MediaServer>> = holder.servers
68
69 private val _rescanState = MutableStateFlow<RescanState>(RescanState.Idle)
70 val rescanState: StateFlow<RescanState> = _rescanState.asStateFlow()
71
72 private var rescanJob: Job? = null
73
74 /** The last finished rescan outcome the UI has already announced. The
75 * outcome is held on screen for a few seconds; without this, a toolbar
76 * that re-enters composition during that hold would announce it (and
77 * reset browse) twice. Identity, not equality: two rescans of the same
78 * server produce equal-but-distinct states. */
79 private var announcedOutcome: RescanState? = null
80
81 /** True exactly once per finished (Completed/Failed) rescan outcome. */
82 @Synchronized
83 fun claimOutcome(state: RescanState): Boolean {
84 if (state !is RescanState.Completed && state !is RescanState.Failed) return false
85 if (state === announcedOutcome) return false
86 announcedOutcome = state
87 return true
88 }
89
90 /** Force an SSDP M-SEARCH ("Refresh devices"). Does not touch the server's
91 * library — see [rescanLibrary] for that. Also restarts the
92 * [searchSettled] window, so the empty state says "Searching…" again. */
93 fun rescan() {
94 holder.rescan()
95 searchRound.value++
96 }
97
98 private val searchRound = MutableStateFlow(0)
99
100 /**
101 * False for [SEARCH_SETTLE_MS] after launch and after each [rescan], then
102 * true. With no servers, the UI shows "Searching…" while this is false and
103 * "No servers found" after (Android ServersScreen; Android: an endless
104 * "Searching…" is a dead screen).
105 */
106 @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
107 val searchSettled: StateFlow<Boolean> = searchRound
108 .transformLatest {
109 emit(false)
110 delay(SEARCH_SETTLE_MS)
111 emit(true)
112 }
113 .stateIn(viewModelScope, SharingStarted.Eagerly, false)
114
115 /** Ask the MinimServer at [udn] to re-index its library, then follow the
116 * restart cycle: Requesting → WaitingForServer(seenDisappear=false) →
117 * WaitingForServer(true) → Completed (held ~4 s) → Idle, or Failed
118 * (held ~4 s) → Idle. No-op while another rescan is in flight. */
119 fun rescanLibrary(udn: String) {
120 if (_rescanState.value.inFlight) return
121 val friendlyName = holder.servers.value.firstOrNull { it.udn == udn }?.friendlyName ?: udn
122 rescanJob?.cancel()
123 rescanJob = viewModelScope.launch {
124 _rescanState.value = RescanState.Requesting(udn, friendlyName)
125 when (val r = minim.rescanLibrary(udn)) {
126 is MinimServerControl.Result.Failure -> fail(udn, friendlyName, r.message)
127 MinimServerControl.Result.Success -> awaitServerCycle(udn, friendlyName)
128 }
129 }
130 }
131
132 private suspend fun fail(udn: String, friendlyName: String, message: String) {
133 _rescanState.value = RescanState.Failed(udn, friendlyName, message)
134 delay(FAILURE_HOLD_MS)
135 if (_rescanState.value is RescanState.Failed) _rescanState.value = RescanState.Idle
136 }
137
138 private suspend fun awaitServerCycle(udn: String, friendlyName: String) {
139 _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = false)
140
141 // Phase 1: the server should drop out of the registry within seconds.
142 val disappeared = withTimeoutOrNull(DISAPPEAR_TIMEOUT_MS) {
143 holder.servers.first { list -> list.none { it.udn == udn } }
144 }
145 if (disappeared == null) {
146 fail(udn, friendlyName, "the server didn't restart, so the rescan may not have happened")
147 return
148 }
149 _rescanState.value = RescanState.WaitingForServer(udn, friendlyName, seenDisappear = true)
150
151 // Phase 2: wait for it to come back, nudging SSDP every few seconds —
152 // and KEEP nudging through the Completed hold: jUPnP's grip on a
153 // freshly-restarted MinimServer is fragile and it can expire the
154 // device again before the server's own NOTIFY heartbeat arrives.
155 val reappeared = coroutineScope {
156 val ssdpJob = launch {
157 while (true) {
158 holder.rescan()
159 delay(SSDP_NUDGE_INTERVAL_MS)
160 }
161 }
162 try {
163 val r = withTimeoutOrNull(REAPPEAR_TIMEOUT_MS) {
164 holder.servers.first { list -> list.any { it.udn == udn } }
165 } ?: return@coroutineScope null
166 // Library changed — the random pool is stale. Browse caches
167 // refetch lazily on next browse.
168 cd.invalidateRandomCache()
169 _rescanState.value = RescanState.Completed(udn, friendlyName)
170 delay(COMPLETED_HOLD_MS)
171 if (_rescanState.value is RescanState.Completed) _rescanState.value = RescanState.Idle
172 r
173 } finally {
174 ssdpJob.cancel()
175 }
176 }
177 if (reappeared == null) {
178 fail(udn, friendlyName, "the server didn't come back after 10 minutes — try Refresh devices")
179 }
180 }
181
182 companion object {
183 private const val COMPLETED_HOLD_MS = 4_000L
184 private const val FAILURE_HOLD_MS = 4_000L
185 private const val DISAPPEAR_TIMEOUT_MS = 30_000L
186 private const val REAPPEAR_TIMEOUT_MS = 10 * 60 * 1000L
187 private const val SSDP_NUDGE_INTERVAL_MS = 3_000L
188 /** Android's empty-state grace before "No servers found". */
189 private const val SEARCH_SETTLE_MS = 6_000L
190 }
191}