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

LyricsViewModel.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.lyrics
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dagger.hilt.android.lifecycle.HiltViewModel
6import dev.stave.cast.raw.RawCastObserver
7import dev.stave.data.LyricsHighlightColor
8import dev.stave.data.SettingsStore
9import dev.stave.domain.model.DidlItem
10import dev.stave.domain.renderer.PlaybackState
11import dev.stave.domain.renderer.Renderer
12import kotlinx.coroutines.ExperimentalCoroutinesApi
13import kotlinx.coroutines.flow.Flow
14import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.combine
18import kotlinx.coroutines.flow.distinctUntilChanged
19import kotlinx.coroutines.flow.flatMapLatest
20import kotlinx.coroutines.flow.map
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.launch
23import javax.inject.Inject
24
25/**
26 * State for the lyrics screen. [result] is the fetch outcome for whichever track is
27 * open; [currentLineIndex] is the active line within that result's synced lyrics
28 * (derived from renderer position, -1 when there are no synced lyrics or before the
29 * first line's timestamp).
30 */
31data class LyricsUiState(
32 val track: DidlItem.Track? = null,
33 val result: LyricsResult = LyricsResult.Loading,
34 val currentLineIndex: Int = -1,
35)
36
37@OptIn(ExperimentalCoroutinesApi::class)
38@HiltViewModel
39class LyricsViewModel @Inject constructor(
40 private val repository: LyricsRepository,
41 private val renderer: Renderer,
42 private val rawCast: RawCastObserver,
43 private val radio: dev.stave.cast.radio.RadioStatusClient,
44 settings: SettingsStore,
45) : ViewModel() {
46
47 private val _result = MutableStateFlow<LyricsResult>(LyricsResult.Loading)
48 private val _track = MutableStateFlow<DidlItem.Track?>(null)
49
50 val highlightSize: StateFlow<Float> = settings.lyricsHighlightSize.stateIn(
51 viewModelScope, SharingStarted.Eagerly, 1.5f,
52 )
53 val highlightBold: StateFlow<Boolean> = settings.lyricsHighlightBold.stateIn(
54 viewModelScope, SharingStarted.Eagerly, false,
55 )
56 val highlightColor: StateFlow<LyricsHighlightColor> = settings.lyricsHighlightColor.stateIn(
57 viewModelScope, SharingStarted.Eagerly, LyricsHighlightColor.AMBER,
58 )
59 val showMetadata: StateFlow<Boolean> = settings.lyricsMetadataEnabled.stateIn(
60 viewModelScope, SharingStarted.Eagerly, true,
61 )
62
63 /**
64 * Returns the active playback state — the cast observer's state when
65 * we're observing a Cast session, otherwise the local renderer state.
66 * Used to drive both "which track has lyrics open" and "which line is
67 * currently active." When observing, the local renderer is paused with
68 * volume=0 and its position is irrelevant.
69 */
70 private fun activeStateFlow(): Flow<PlaybackState> =
71 rawCast.observing.flatMapLatest { isObserving ->
72 if (isObserving) rawCast.playbackState else renderer.state
73 }
74
75 /**
76 * Derived state. The architecture:
77 * 1. Combine [_track] and [_result] into a (track, result) pair flow.
78 * 2. flatMapLatest re-subscribes to the active state flow every time
79 * the pair changes. The CRITICAL property: when result transitions
80 * from Loading to Loaded, flatMapLatest cancels the old inner
81 * flow and starts a new one — which immediately emits the
82 * LATEST state value. That emission carries the live
83 * position, so currentLineIndex is computed correctly without
84 * relying on a subsequent position tick.
85 * 3. Inside the inner flow, every position tick produces a fresh
86 * LyricsUiState. distinctUntilChanged on the state flow itself
87 * drops emissions where currentLineIndex is unchanged.
88 */
89 val state: StateFlow<LyricsUiState> = combine(_track, _result) { track, result ->
90 track to result
91 }.flatMapLatest { (track, result) ->
92 activeStateFlow().map { ps ->
93 val idx = when (result) {
94 is LyricsResult.Loaded -> indexFor(
95 result.synced,
96 ps.position.inWholeMilliseconds,
97 )
98 else -> -1
99 }
100 LyricsUiState(track = track, result = result, currentLineIndex = idx)
101 }
102 }
103 .distinctUntilChanged()
104 .stateIn(viewModelScope, SharingStarted.Eagerly, LyricsUiState())
105
106 private var followJob: kotlinx.coroutines.Job? = null
107
108 fun openForCurrent() {
109 followJob?.cancel()
110 followJob = viewModelScope.launch {
111 // Same observing-aware source switch as activeStateFlow() —
112 // tracks the cast observer's "current" when observing so
113 // navigating to lyrics from the now-playing screen during a
114 // followed CCA session opens lyrics for what's on the speaker.
115 rawCast.observing.flatMapLatest { isObserving ->
116 if (isObserving) rawCast.playbackState else renderer.state
117 }
118 .map { it.current }
119 // Station overlay (review N.67, the maintainer's order 2026-09-02):
120 // while following a radio station the observed "track" is the
121 // station's static title ("91.9 Classical KC"); the piece
122 // actually playing comes from the radio overlay the Now
123 // Playing screen already shows. Look THAT up, and give it an
124 // identity of its own so the lyrics cache is per piece, not
125 // per stream (N.68).
126 .combine(radio.now) { cur, rn ->
127 if (cur != null && rn != null && rn.title != null) {
128 val composer = rn.artist?.substringBefore(" · ")?.trim()?.ifBlank { null }
129 cur.copy(
130 title = rn.title,
131 artist = composer,
132 albumArtist = composer,
133 album = rn.station,
134 resourceUrl = "station:${rn.station}|${rn.title}|${composer ?: ""}",
135 )
136 } else cur
137 }
138 .distinctUntilChanged { a, b -> a?.id == b?.id && a?.title == b?.title && a?.artist == b?.artist }
139 .collect { track ->
140 if (track == null) {
141 _track.value = null
142 _result.value = LyricsResult.NotFound
143 } else {
144 open(track)
145 }
146 }
147 }
148 }
149
150 fun open(track: DidlItem.Track) {
151 _track.value = track
152 _result.value = LyricsResult.Loading
153 viewModelScope.launch {
154 val fetched = repository.fetch(track)
155 _result.value = fetched
156 }
157 }
158
159 fun refresh() {
160 val track = _track.value ?: return
161 _result.value = LyricsResult.Loading
162 viewModelScope.launch {
163 val fetched = repository.fetchFresh(track, skipDuration = true)
164 _result.value = fetched
165 }
166 }
167
168 fun close() {
169 followJob?.cancel()
170 followJob = null
171 _track.value = null
172 _result.value = LyricsResult.Loading
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.
178 */
179 private fun indexFor(lines: List<LrcLine>, millis: Long): Int {
180 if (lines.isEmpty()) return -1
181 var lo = 0
182 var hi = lines.size - 1
183 var best = -1
184 while (lo <= hi) {
185 val mid = (lo + hi) ushr 1
186 val t = lines[mid].start.inWholeMilliseconds
187 if (t <= millis) {
188 best = mid
189 lo = mid + 1
190 } else {
191 hi = mid - 1
192 }
193 }
194 return best
195 }
196}