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
+80
-39
No files matched your search
@@ -64,10 +64,12 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
|
- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
|
||||||
- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
|
- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
|
||||||
- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
|
- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
|
||||||
|
- **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
||||||
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
||||||
|
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.media3.common.Timeline
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.novasonic.app.NovaSonicApp
|
import com.novasonic.app.NovaSonicApp
|
||||||
|
import com.novasonic.app.api.LrcLibClient
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
import com.novasonic.app.data.MusicRepository
|
import com.novasonic.app.data.MusicRepository
|
||||||
import kotlinx.coroutines.*
|
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 {
|
private inner class PlayerListener : Player.Listener {
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
if (isPlaying) startTicker() else stopTicker()
|
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)
|
val dur = ctrl.duration.coerceAtLeast(0)
|
||||||
scheduleScrobble(song, dur)
|
scheduleScrobble(song, dur)
|
||||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||||
|
prefetchLyrics(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
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.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@@ -399,31 +400,35 @@ fun NowPlayingScreen(
|
|||||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.LYRICS && song != null) {
|
if (tab == NpTab.LYRICS && song != null) {
|
||||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||||
val lyricsScrollState = rememberScrollState()
|
val lyricsListState = rememberLazyListState()
|
||||||
val density = LocalDensity.current
|
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) }
|
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
|
// 200ms lookahead — same as web app for better sync feel
|
||||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
|
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
|
||||||
else -1
|
else -1
|
||||||
|
|
||||||
// Center the active line when it changes
|
// Center the active line using its *measured* position — no cumulative drift, so
|
||||||
LaunchedEffect(activeIdx) {
|
// 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
|
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||||
val target = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
|
val info = lyricsListState.layoutInfo
|
||||||
- lyricsViewH * 0.5f).coerceAtLeast(0f).toInt()
|
val anchor = info.viewportStartOffset +
|
||||||
// Spring handles rapid line changes gracefully — starts from current scroll velocity
|
((info.viewportEndOffset - info.viewportStartOffset) * anchorFrac).toInt()
|
||||||
lyricsScrollState.animateScrollTo(
|
val item = info.visibleItemsInfo.firstOrNull { it.index == activeIdx }
|
||||||
target,
|
if (item != null) {
|
||||||
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow),
|
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 {
|
when {
|
||||||
@@ -446,17 +451,20 @@ fun NowPlayingScreen(
|
|||||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
|
|
||||||
lyricsState.lines.isNotEmpty() -> Column(
|
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||||
|
state = lyricsListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged { lyricsViewH = it.height }
|
.onSizeChanged { lyricsViewH = it.height },
|
||||||
.verticalScroll(lyricsScrollState),
|
// 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,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(200.dp))
|
itemsIndexed(lyricsState.lines, key = { idx, _ -> idx }) { idx, line ->
|
||||||
lyricsState.lines.forEachIndexed { idx, line ->
|
|
||||||
key(idx) {
|
|
||||||
LyricsLineItem(
|
LyricsLineItem(
|
||||||
text = line.text,
|
text = line.text,
|
||||||
isActive = idx == activeIdx,
|
isActive = idx == activeIdx,
|
||||||
@@ -465,8 +473,6 @@ fun NowPlayingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(200.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
|||||||
+33
-16
@@ -145,7 +145,6 @@ async function _loadNpLyrics(songId) {
|
|||||||
}
|
}
|
||||||
_lyricsSongId = songId;
|
_lyricsSongId = songId;
|
||||||
_lyricsLines = [];
|
_lyricsLines = [];
|
||||||
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
|
|
||||||
|
|
||||||
const token = ++_lyricsLoadToken;
|
const token = ++_lyricsLoadToken;
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
@@ -155,6 +154,28 @@ async function _loadNpLyrics(songId) {
|
|||||||
const dur = s?.duration || 0;
|
const dur = s?.duration || 0;
|
||||||
const cacheKey = `${artist}|${title}`;
|
const cacheKey = `${artist}|${title}`;
|
||||||
|
|
||||||
|
// Cache hit (warmed on play, or from a previous view) — render instantly, no placeholder flash
|
||||||
|
if(_lyricsCache.has(cacheKey)) {
|
||||||
|
content.innerHTML = '';
|
||||||
|
_showLyricsResult(content, _lyricsCache.get(cacheKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
|
||||||
|
const result = await _fetchLyricsData(artist, title, album, dur);
|
||||||
|
if(token !== _lyricsLoadToken) return;
|
||||||
|
content.innerHTML = '';
|
||||||
|
_showLyricsResult(content, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure lrclib fetch cascade — no DOM. Populates _lyricsCache and returns the result.
|
||||||
|
* Shared by the Lyrics tab and by prefetchLyrics() so opening Lyrics feels instant.
|
||||||
|
*/
|
||||||
|
async function _fetchLyricsData(artist, title, album, dur) {
|
||||||
|
const cacheKey = `${artist}|${title}`;
|
||||||
|
if(_lyricsCache.has(cacheKey)) return _lyricsCache.get(cacheKey);
|
||||||
|
|
||||||
const SIG = () => AbortSignal.timeout(6000);
|
const SIG = () => AbortSignal.timeout(6000);
|
||||||
const safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
|
const safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
|
||||||
const bestMatch = (candidates, durSecs) => {
|
const bestMatch = (candidates, durSecs) => {
|
||||||
@@ -166,19 +187,12 @@ async function _loadNpLyrics(songId) {
|
|||||||
return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]);
|
return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache hit — instant
|
let result = null;
|
||||||
if(_lyricsCache.has(cacheKey)) {
|
|
||||||
const cached = _lyricsCache.get(cacheKey);
|
|
||||||
content.innerHTML = '';
|
|
||||||
_showLyricsResult(content, cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1a. lrclib exact match with duration
|
// 1a. lrclib exact match with duration
|
||||||
const exactP = new URLSearchParams({ artist_name: artist, track_name: title });
|
const exactP = new URLSearchParams({ artist_name: artist, track_name: title });
|
||||||
if(album) exactP.set('album_name', album);
|
if(album) exactP.set('album_name', album);
|
||||||
if(dur) exactP.set('duration', dur);
|
if(dur) exactP.set('duration', dur);
|
||||||
let result = null;
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`https://lrclib.net/api/get?${exactP}`, { signal: SIG() });
|
const r = await fetch(`https://lrclib.net/api/get?${exactP}`, { signal: SIG() });
|
||||||
if(r.status === 200) {
|
if(r.status === 200) {
|
||||||
@@ -199,8 +213,6 @@ async function _loadNpLyrics(songId) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(token !== _lyricsLoadToken) return;
|
|
||||||
|
|
||||||
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
|
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
|
||||||
if(!result) {
|
if(!result) {
|
||||||
const artistP = new URLSearchParams({ track_name: title });
|
const artistP = new URLSearchParams({ track_name: title });
|
||||||
@@ -214,12 +226,15 @@ async function _loadNpLyrics(songId) {
|
|||||||
result = bestMatch(artistData, dur) ?? bestMatch(titleData, dur);
|
result = bestMatch(artistData, dur) ?? bestMatch(titleData, dur);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(token !== _lyricsLoadToken) return;
|
|
||||||
|
|
||||||
if(token !== _lyricsLoadToken) return;
|
|
||||||
_lyricsCache.set(cacheKey, result ?? null);
|
_lyricsCache.set(cacheKey, result ?? null);
|
||||||
content.innerHTML = '';
|
return result ?? null;
|
||||||
_showLyricsResult(content, result);
|
}
|
||||||
|
|
||||||
|
/** Warm the lyrics cache when a track starts, so opening the Lyrics tab is instant. */
|
||||||
|
function prefetchLyrics(song) {
|
||||||
|
if(!song || !song.title) return;
|
||||||
|
const artist = (song.artist || '').split(/[/,]/)[0].trim();
|
||||||
|
_fetchLyricsData(artist, song.title, song.album || '', song.duration || 0).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showLyricsResult(content, result) {
|
function _showLyricsResult(content, result) {
|
||||||
@@ -268,6 +283,8 @@ export function onNpSongChange(songId) {
|
|||||||
_lyricsLines = [];
|
_lyricsLines = [];
|
||||||
const content = document.getElementById('np-lyrics-content');
|
const content = document.getElementById('np-lyrics-content');
|
||||||
if(content) content.innerHTML = '';
|
if(content) content.innerHTML = '';
|
||||||
|
// Warm the cache the moment the song changes, so the Lyrics tab opens instantly.
|
||||||
|
prefetchLyrics(S.queue.find(x => x.id === songId) || S.queue[S.qIdx]);
|
||||||
if(S.npLyricsMode) _loadNpLyrics(songId);
|
if(S.npLyricsMode) _loadNpLyrics(songId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user