package dev.stave.lyrics import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.stave.cast.raw.RawCastObserver import dev.stave.data.LyricsHighlightColor import dev.stave.data.SettingsStore import dev.stave.domain.model.DidlItem import dev.stave.domain.renderer.PlaybackState import dev.stave.domain.renderer.Renderer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject /** * State for the lyrics screen. [result] is the fetch outcome for whichever track is * open; [currentLineIndex] is the active line within that result's synced lyrics * (derived from renderer position, -1 when there are no synced lyrics or before the * first line's timestamp). */ data class LyricsUiState( val track: DidlItem.Track? = null, val result: LyricsResult = LyricsResult.Loading, val currentLineIndex: Int = -1, ) @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class LyricsViewModel @Inject constructor( private val repository: LyricsRepository, private val renderer: Renderer, private val rawCast: RawCastObserver, private val radio: dev.stave.cast.radio.RadioStatusClient, settings: SettingsStore, ) : ViewModel() { private val _result = MutableStateFlow(LyricsResult.Loading) private val _track = MutableStateFlow(null) val highlightSize: StateFlow = settings.lyricsHighlightSize.stateIn( viewModelScope, SharingStarted.Eagerly, 1.5f, ) val highlightBold: StateFlow = settings.lyricsHighlightBold.stateIn( viewModelScope, SharingStarted.Eagerly, false, ) val highlightColor: StateFlow = settings.lyricsHighlightColor.stateIn( viewModelScope, SharingStarted.Eagerly, LyricsHighlightColor.AMBER, ) val showMetadata: StateFlow = settings.lyricsMetadataEnabled.stateIn( viewModelScope, SharingStarted.Eagerly, true, ) /** * Returns the active playback state — the cast observer's state when * we're observing a Cast session, otherwise the local renderer state. * Used to drive both "which track has lyrics open" and "which line is * currently active." When observing, the local renderer is paused with * volume=0 and its position is irrelevant. */ private fun activeStateFlow(): Flow = rawCast.observing.flatMapLatest { isObserving -> if (isObserving) rawCast.playbackState else renderer.state } /** * Derived state. The architecture: * 1. Combine [_track] and [_result] into a (track, result) pair flow. * 2. flatMapLatest re-subscribes to the active state flow every time * the pair changes. The CRITICAL property: when result transitions * from Loading to Loaded, flatMapLatest cancels the old inner * flow and starts a new one — which immediately emits the * LATEST state value. That emission carries the live * position, so currentLineIndex is computed correctly without * relying on a subsequent position tick. * 3. Inside the inner flow, every position tick produces a fresh * LyricsUiState. distinctUntilChanged on the state flow itself * drops emissions where currentLineIndex is unchanged. */ val state: StateFlow = combine(_track, _result) { track, result -> track to result }.flatMapLatest { (track, result) -> activeStateFlow().map { ps -> val idx = when (result) { is LyricsResult.Loaded -> indexFor( result.synced, ps.position.inWholeMilliseconds, ) else -> -1 } LyricsUiState(track = track, result = result, currentLineIndex = idx) } } .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Eagerly, LyricsUiState()) private var followJob: kotlinx.coroutines.Job? = null fun openForCurrent() { followJob?.cancel() followJob = viewModelScope.launch { // Same observing-aware source switch as activeStateFlow() — // tracks the cast observer's "current" when observing so // navigating to lyrics from the now-playing screen during a // followed CCA session opens lyrics for what's on the speaker. rawCast.observing.flatMapLatest { isObserving -> if (isObserving) rawCast.playbackState else renderer.state } .map { it.current } // Station overlay (review N.67, the maintainer's order 2026-09-02): // while following a radio station the observed "track" is the // station's static title ("91.9 Classical KC"); the piece // actually playing comes from the radio overlay the Now // Playing screen already shows. Look THAT up, and give it an // identity of its own so the lyrics cache is per piece, not // per stream (N.68). .combine(radio.now) { cur, rn -> if (cur != null && rn != null && rn.title != null) { val composer = rn.artist?.substringBefore(" · ")?.trim()?.ifBlank { null } cur.copy( title = rn.title, artist = composer, albumArtist = composer, album = rn.station, resourceUrl = "station:${rn.station}|${rn.title}|${composer ?: ""}", ) } else cur } .distinctUntilChanged { a, b -> a?.id == b?.id && a?.title == b?.title && a?.artist == b?.artist } .collect { track -> if (track == null) { _track.value = null _result.value = LyricsResult.NotFound } else { open(track) } } } } fun open(track: DidlItem.Track) { _track.value = track _result.value = LyricsResult.Loading viewModelScope.launch { val fetched = repository.fetch(track) _result.value = fetched } } fun refresh() { val track = _track.value ?: return _result.value = LyricsResult.Loading viewModelScope.launch { val fetched = repository.fetchFresh(track, skipDuration = true) _result.value = fetched } } fun close() { followJob?.cancel() followJob = null _track.value = null _result.value = LyricsResult.Loading } /** * Binary-search the lines list for the largest index whose start is ≤ [millis]. * Returns -1 before the first line. O(log n) per tick. */ private fun indexFor(lines: List, millis: Long): Int { if (lines.isEmpty()) return -1 var lo = 0 var hi = lines.size - 1 var best = -1 while (lo <= hi) { val mid = (lo + hi) ushr 1 val t = lines[mid].start.inWholeMilliseconds if (t <= millis) { best = mid lo = mid + 1 } else { hi = mid - 1 } } return best } }