LyricsViewModel.kt
196 lines · 8.0 KB
Changed in: 0.5.0
| 1 | package dev.stave.lyrics |
| 2 | |
| 3 | import androidx.lifecycle.ViewModel |
| 4 | import androidx.lifecycle.viewModelScope |
| 5 | import dagger.hilt.android.lifecycle.HiltViewModel |
| 6 | import dev.stave.cast.raw.RawCastObserver |
| 7 | import dev.stave.data.LyricsHighlightColor |
| 8 | import dev.stave.data.SettingsStore |
| 9 | import dev.stave.domain.model.DidlItem |
| 10 | import dev.stave.domain.renderer.PlaybackState |
| 11 | import dev.stave.domain.renderer.Renderer |
| 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 13 | import kotlinx.coroutines.flow.Flow |
| 14 | import kotlinx.coroutines.flow.MutableStateFlow |
| 15 | import kotlinx.coroutines.flow.SharingStarted |
| 16 | import kotlinx.coroutines.flow.StateFlow |
| 17 | import kotlinx.coroutines.flow.combine |
| 18 | import kotlinx.coroutines.flow.distinctUntilChanged |
| 19 | import kotlinx.coroutines.flow.flatMapLatest |
| 20 | import kotlinx.coroutines.flow.map |
| 21 | import kotlinx.coroutines.flow.stateIn |
| 22 | import kotlinx.coroutines.launch |
| 23 | import 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 | */ |
| 31 | data 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 |
| 39 | class 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 | } |


