package dev.stave.lyrics import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.stave.cast.raw.CastObserverApi import dev.stave.ui.safeLaunch 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.asStateFlow 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 /** * State for the lyrics overlay. [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, ) class LyricsViewModel( private val repository: LyricsRepository, private val renderer: Renderer, private val rawCast: CastObserverApi, private val settings: dev.stave.data.SettingsStore, ) : ViewModel() { private val _result = MutableStateFlow(LyricsResult.Loading) private val _track = MutableStateFlow(null) /** Effective playback state for lyrics sync: observer's view when following * a CCA group, otherwise the local renderer's. Same flatMapLatest pattern * as [NowPlayingViewModel.tvState] — when [CastObserverApi.observing] flips, * the source flow swaps without resubscribing the downstream collector. */ @OptIn(ExperimentalCoroutinesApi::class) private val effectiveState: Flow = rawCast.observing .flatMapLatest { obs -> if (obs) rawCast.playbackState else renderer.state } /** * Derived state. Every time the effective position or our fetch result changes, * recompute the line index. For plain-only or no-lyrics states the index is -1. * Also subscribes to settings.lyricsOffsetMs so the user's offset changes take * effect immediately without requiring the overlay to be closed and reopened. */ val state: StateFlow = combine( _track, _result, effectiveState, settings.lyricsOffsetMs, ) { track, result, playbackState, offsetMs -> val idx = when (result) { is LyricsResult.Loaded -> { // Apply user-configurable offset. Positive offset = lyrics advance // later (subtract from effective position so timestamps are reached // later in wall-clock time); negative = lyrics advance earlier. val effective = playbackState.position.inWholeMilliseconds - offsetMs indexFor(result.synced, effective) } else -> -1 } LyricsUiState(track = track, result = result, currentLineIndex = idx) }.stateIn(viewModelScope, SharingStarted.Eagerly, LyricsUiState()) private var followJob: kotlinx.coroutines.Job? = null /** The one fetch in flight. A new open / refresh / close cancels it. */ private var fetchJob: kotlinx.coroutines.Job? = null /** Bumped by every open / refresh / close. A fetch only publishes its result if * the generation it started under is still current, so a slow lookup for the * previous track can never land under the track now showing — even if it * finishes in the instant between cancel and completion. */ private val generation = java.util.concurrent.atomic.AtomicLong(0) private fun startFetch(track: DidlItem.Track, fetch: suspend () -> LyricsResult) { fetchJob?.cancel() val gen = generation.incrementAndGet() _track.value = track _result.value = LyricsResult.Loading fetchJob = safeLaunch { val r = fetch() if (generation.get() == gen) _result.value = r } } /** * Open lyrics for the currently-playing track AND enter follow-current mode: any * subsequent track change (auto-advance, user skip, shuffle) triggers a fresh * fetch for the new track. [close] exits this mode. * * The distinctUntilChanged-by-id filter keeps position ticks from re-triggering * fetches — the state flow re-emits on every 50ms position update, but we only * care about genuine track identity changes. * * Switches between the local renderer and the observed cast session based on * [CastObserverApi.observing] — picking Follow on a CCA group while lyrics are * open will start fetching for the followed track on its next id change. */ fun openForCurrent() { // Cancel any stale follow job before starting a fresh one. Prevents duplicate // fetches if openForCurrent() is called twice (e.g. reopen without close). followJob?.cancel() followJob = safeLaunch { effectiveState .map { it.current } .distinctUntilChanged { a, b -> a?.id == b?.id } .collect { track -> if (track == null) { fetchJob?.cancel() generation.incrementAndGet() _track.value = null _result.value = LyricsResult.NotFound } else { open(track) } } } } /** * Open lyrics for [track]. Clears prior state, kicks off a fetch. Does NOT start * follow-current mode — use [openForCurrent] for that. Exists as a hook for * future "show lyrics for a specific (non-current) track" flows. */ fun open(track: DidlItem.Track) { startFetch(track) { repository.fetch(track) } } /** Clear the overlay state AND stop following the current track. Idempotent. */ fun close() { followJob?.cancel() followJob = null fetchJob?.cancel() fetchJob = null generation.incrementAndGet() _track.value = null _result.value = LyricsResult.Loading } /** * Re-fetch lyrics for the currently-tracked song without altering the * follow-current-track subscription. Used after the user toggles * `allowExternalCalls` back on (to force a real fetch where the previous * result was [LyricsResult.PrivacyDisabled]), as a manual retry after * a network blip yielded [LyricsResult.Error], or any other case where * the user wants Earhorn to try LRCLIB again. * * The duration is left out of the request: the user pressed Refresh because * the match was bad, and the duration filter is often what drops the better * variant (as on Android). * * No-op when no track is open — there's nothing to refresh. */ fun refresh() { val track = _track.value ?: return startFetch(track) { repository.fetchFresh(track, skipDuration = true) } } /** * Binary-search the lines list for the largest index whose start is ≤ [millis]. * Returns -1 before the first line. O(log n) per tick — lists can be hundreds of * lines for a chatty pop song, so this matters. */ 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 } }