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>(LyricsResult.Loading)
private val _track = MutableStateFlow<DidlItem.Track?>(null)
val highlightSize: StateFlow<Float> = settings.lyricsHighlightSize.stateIn(
viewModelScope, SharingStarted.Eagerly, 1.5f,
)
val highlightBold: StateFlow<Boolean> = settings.lyricsHighlightBold.stateIn(
viewModelScope, SharingStarted.Eagerly, false,
)
val highlightColor: StateFlow<LyricsHighlightColor> = settings.lyricsHighlightColor.stateIn(
viewModelScope, SharingStarted.Eagerly, LyricsHighlightColor.AMBER,
)
val showMetadata: StateFlow<Boolean> = 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<PlaybackState> =
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<LyricsUiState> = 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<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
}
}