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

LyricsViewModel.kt

197 lines · 8.1 KB

Changed in: 0.5.0

1package dev.stave.lyrics
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dev.stave.cast.raw.CastObserverApi
6import dev.stave.ui.safeLaunch
7import dev.stave.domain.model.DidlItem
8import dev.stave.domain.renderer.PlaybackState
9import dev.stave.domain.renderer.Renderer
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.flow.Flow
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.SharingStarted
14import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.combine
17import kotlinx.coroutines.flow.distinctUntilChanged
18import kotlinx.coroutines.flow.flatMapLatest
19import kotlinx.coroutines.flow.map
20import kotlinx.coroutines.flow.stateIn
21import kotlinx.coroutines.launch
22
23/**
24 * State for the lyrics overlay. [result] is the fetch outcome for whichever track
25 * is open; [currentLineIndex] is the active line within that result's synced lyrics
26 * (derived from renderer position, -1 when there are no synced lyrics or before the
27 * first line's timestamp).
28 */
29data class LyricsUiState(
30 val track: DidlItem.Track? = null,
31 val result: LyricsResult = LyricsResult.Loading,
32 val currentLineIndex: Int = -1,
33)
34
35class LyricsViewModel(
36 private val repository: LyricsRepository,
37 private val renderer: Renderer,
38 private val rawCast: CastObserverApi,
39 private val settings: dev.stave.data.SettingsStore,
40) : ViewModel() {
41
42 private val _result = MutableStateFlow<LyricsResult>(LyricsResult.Loading)
43 private val _track = MutableStateFlow<DidlItem.Track?>(null)
44
45 /** Effective playback state for lyrics sync: observer's view when following
46 * a CCA group, otherwise the local renderer's. Same flatMapLatest pattern
47 * as [NowPlayingViewModel.tvState] — when [CastObserverApi.observing] flips,
48 * the source flow swaps without resubscribing the downstream collector. */
49 @OptIn(ExperimentalCoroutinesApi::class)
50 private val effectiveState: Flow<PlaybackState> = rawCast.observing
51 .flatMapLatest { obs ->
52 if (obs) rawCast.playbackState else renderer.state
53 }
54
55 /**
56 * Derived state. Every time the effective position or our fetch result changes,
57 * recompute the line index. For plain-only or no-lyrics states the index is -1.
58 * Also subscribes to settings.lyricsOffsetMs so the user's offset changes take
59 * effect immediately without requiring the overlay to be closed and reopened.
60 */
61 val state: StateFlow<LyricsUiState> = combine(
62 _track,
63 _result,
64 effectiveState,
65 settings.lyricsOffsetMs,
66 ) { track, result, playbackState, offsetMs ->
67 val idx = when (result) {
68 is LyricsResult.Loaded -> {
69 // Apply user-configurable offset. Positive offset = lyrics advance
70 // later (subtract from effective position so timestamps are reached
71 // later in wall-clock time); negative = lyrics advance earlier.
72 val effective = playbackState.position.inWholeMilliseconds - offsetMs
73 indexFor(result.synced, effective)
74 }
75 else -> -1
76 }
77 LyricsUiState(track = track, result = result, currentLineIndex = idx)
78 }.stateIn(viewModelScope, SharingStarted.Eagerly, LyricsUiState())
79
80 private var followJob: kotlinx.coroutines.Job? = null
81
82 /** The one fetch in flight. A new open / refresh / close cancels it. */
83 private var fetchJob: kotlinx.coroutines.Job? = null
84
85 /** Bumped by every open / refresh / close. A fetch only publishes its result if
86 * the generation it started under is still current, so a slow lookup for the
87 * previous track can never land under the track now showing — even if it
88 * finishes in the instant between cancel and completion. */
89 private val generation = java.util.concurrent.atomic.AtomicLong(0)
90
91 private fun startFetch(track: DidlItem.Track, fetch: suspend () -> LyricsResult) {
92 fetchJob?.cancel()
93 val gen = generation.incrementAndGet()
94 _track.value = track
95 _result.value = LyricsResult.Loading
96 fetchJob = safeLaunch {
97 val r = fetch()
98 if (generation.get() == gen) _result.value = r
99 }
100 }
101
102 /**
103 * Open lyrics for the currently-playing track AND enter follow-current mode: any
104 * subsequent track change (auto-advance, user skip, shuffle) triggers a fresh
105 * fetch for the new track. [close] exits this mode.
106 *
107 * The distinctUntilChanged-by-id filter keeps position ticks from re-triggering
108 * fetches — the state flow re-emits on every 50ms position update, but we only
109 * care about genuine track identity changes.
110 *
111 * Switches between the local renderer and the observed cast session based on
112 * [CastObserverApi.observing] — picking Follow on a CCA group while lyrics are
113 * open will start fetching for the followed track on its next id change.
114 */
115 fun openForCurrent() {
116 // Cancel any stale follow job before starting a fresh one. Prevents duplicate
117 // fetches if openForCurrent() is called twice (e.g. reopen without close).
118 followJob?.cancel()
119 followJob = safeLaunch {
120 effectiveState
121 .map { it.current }
122 .distinctUntilChanged { a, b -> a?.id == b?.id }
123 .collect { track ->
124 if (track == null) {
125 fetchJob?.cancel()
126 generation.incrementAndGet()
127 _track.value = null
128 _result.value = LyricsResult.NotFound
129 } else {
130 open(track)
131 }
132 }
133 }
134 }
135
136 /**
137 * Open lyrics for [track]. Clears prior state, kicks off a fetch. Does NOT start
138 * follow-current mode — use [openForCurrent] for that. Exists as a hook for
139 * future "show lyrics for a specific (non-current) track" flows.
140 */
141 fun open(track: DidlItem.Track) {
142 startFetch(track) { repository.fetch(track) }
143 }
144
145 /** Clear the overlay state AND stop following the current track. Idempotent. */
146 fun close() {
147 followJob?.cancel()
148 followJob = null
149 fetchJob?.cancel()
150 fetchJob = null
151 generation.incrementAndGet()
152 _track.value = null
153 _result.value = LyricsResult.Loading
154 }
155
156 /**
157 * Re-fetch lyrics for the currently-tracked song without altering the
158 * follow-current-track subscription. Used after the user toggles
159 * `allowExternalCalls` back on (to force a real fetch where the previous
160 * result was [LyricsResult.PrivacyDisabled]), as a manual retry after
161 * a network blip yielded [LyricsResult.Error], or any other case where
162 * the user wants Earhorn to try LRCLIB again.
163 *
164 * The duration is left out of the request: the user pressed Refresh because
165 * the match was bad, and the duration filter is often what drops the better
166 * variant (as on Android).
167 *
168 * No-op when no track is open — there's nothing to refresh.
169 */
170 fun refresh() {
171 val track = _track.value ?: return
172 startFetch(track) { repository.fetchFresh(track, skipDuration = true) }
173 }
174
175 /**
176 * Binary-search the lines list for the largest index whose start is ≤ [millis].
177 * Returns -1 before the first line. O(log n) per tick — lists can be hundreds of
178 * lines for a chatty pop song, so this matters.
179 */
180 private fun indexFor(lines: List<LrcLine>, millis: Long): Int {
181 if (lines.isEmpty()) return -1
182 var lo = 0
183 var hi = lines.size - 1
184 var best = -1
185 while (lo <= hi) {
186 val mid = (lo + hi) ushr 1
187 val t = lines[mid].start.inWholeMilliseconds
188 if (t <= millis) {
189 best = mid
190 lo = mid + 1
191 } else {
192 hi = mid - 1
193 }
194 }
195 return best
196 }
197}