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>(LyricsResult.Loading)
private val _track = MutableStateFlow<DidlItem.Track?>(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<PlaybackState> = 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<LyricsUiState> = 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<LrcLine>, 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
}
}