feat(lyrics): prefetch on play + smooth measured-position autoscroll
Warm the lrclib cache the moment a track starts (Android PlayerController onMediaItemTransition; web onNpSongChange) so opening the Lyrics tab is instant. Extract a DOM-free fetch helper on web for reuse. Rewrite Android lyrics autoscroll to center on the active line's measured position in a LazyColumn instead of a fixed line-height estimate — removes stutter and the downward drift on wrapped lines; active line sits at a ~40%-from-top reading anchor. CHANGELOG: fold into 1.0; drop the now-fixed lyrics known-issue note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a5197b776
commit
211b8ec6c2
4 files changed
+87
-46
No files matched your search
@@ -8,6 +8,7 @@ import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.*
|
||||
@@ -279,6 +280,20 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
)
|
||||
}
|
||||
|
||||
/** Warm the LrcLib cache as soon as a track starts, so opening Lyrics feels instant. */
|
||||
private fun prefetchLyrics(song: Song) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
@@ -298,6 +313,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val dur = ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||
prefetchLyrics(song)
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -399,31 +400,35 @@ fun NowPlayingScreen(
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val lyricsScrollState = rememberScrollState()
|
||||
val lyricsListState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Pre-compute pixel constants for absolute scroll target calculation
|
||||
val lineHeightPx = remember(density) {
|
||||
with(density) { 28.sp.toPx() + 12.dp.toPx() } // lineHeight + 2×vertical padding
|
||||
}
|
||||
val lyricsPaddingPx = remember(density) { with(density) { 200.dp.toPx() } }
|
||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||
val anchorFrac = 0.40f
|
||||
|
||||
// 200ms lookahead — same as web app for better sync feel
|
||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
|
||||
else -1
|
||||
|
||||
// Center the active line when it changes
|
||||
LaunchedEffect(activeIdx) {
|
||||
// Center the active line using its *measured* position — no cumulative drift, so
|
||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||
LaunchedEffect(activeIdx, lyricsViewH) {
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
val target = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
|
||||
- lyricsViewH * 0.5f).coerceAtLeast(0f).toInt()
|
||||
// Spring handles rapid line changes gracefully — starts from current scroll velocity
|
||||
lyricsScrollState.animateScrollTo(
|
||||
target,
|
||||
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow),
|
||||
)
|
||||
val info = lyricsListState.layoutInfo
|
||||
val anchor = info.viewportStartOffset +
|
||||
((info.viewportEndOffset - info.viewportStartOffset) * anchorFrac).toInt()
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == activeIdx }
|
||||
if (item != null) {
|
||||
val delta = (item.offset + item.size / 2 - anchor).toFloat()
|
||||
lyricsListState.animateScrollBy(
|
||||
delta,
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
|
||||
)
|
||||
} else {
|
||||
// Big jump (instrumental gap → chorus): approximate-anchor now, refine next tick
|
||||
lyricsListState.animateScrollToItem(activeIdx, -anchor)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
@@ -446,26 +451,27 @@ fun NowPlayingScreen(
|
||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
lyricsState.lines.isNotEmpty() -> Column(
|
||||
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||
state = lyricsListState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { lyricsViewH = it.height }
|
||||
.verticalScroll(lyricsScrollState),
|
||||
.onSizeChanged { lyricsViewH = it.height },
|
||||
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
||||
contentPadding = if (lyricsViewH > 0) PaddingValues(
|
||||
top = with(density) { (lyricsViewH * anchorFrac).toDp() },
|
||||
bottom = with(density) { (lyricsViewH * (1f - anchorFrac)).toDp() },
|
||||
) else PaddingValues(vertical = 220.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(200.dp))
|
||||
lyricsState.lines.forEachIndexed { idx, line ->
|
||||
key(idx) {
|
||||
LyricsLineItem(
|
||||
text = line.text,
|
||||
isActive = idx == activeIdx,
|
||||
isPast = idx < activeIdx,
|
||||
onSeek = { controller.seekTo(line.timeMs) },
|
||||
)
|
||||
}
|
||||
itemsIndexed(lyricsState.lines, key = { idx, _ -> idx }) { idx, line ->
|
||||
LyricsLineItem(
|
||||
text = line.text,
|
||||
isActive = idx == activeIdx,
|
||||
isPast = idx < activeIdx,
|
||||
onSeek = { controller.seekTo(line.timeMs) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(200.dp))
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
|
||||
Reference in new issue
Block a user