diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f86840..2698130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** — 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 +- **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 - **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** — 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 - **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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 57f19f4..fe51a42 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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() diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 30632b9..2f8e086 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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( diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index 79226f3..25242f2 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -145,7 +145,6 @@ async function _loadNpLyrics(songId) { } _lyricsSongId = songId; _lyricsLines = []; - content.innerHTML = '
Lade Lyrics…
'; const token = ++_lyricsLoadToken; const s = S.queue[S.qIdx]; @@ -155,6 +154,28 @@ async function _loadNpLyrics(songId) { const dur = s?.duration || 0; 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 = '
Lade Lyrics…
'; + 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 safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } }; const bestMatch = (candidates, durSecs) => { @@ -166,19 +187,12 @@ async function _loadNpLyrics(songId) { return Math.abs(pick.duration - durSecs) <= 5 ? pick : (synced[0] || candidates[0]); }; - // Cache hit — instant - if(_lyricsCache.has(cacheKey)) { - const cached = _lyricsCache.get(cacheKey); - content.innerHTML = ''; - _showLyricsResult(content, cached); - return; - } + let result = null; // 1a. lrclib exact match with duration const exactP = new URLSearchParams({ artist_name: artist, track_name: title }); if(album) exactP.set('album_name', album); if(dur) exactP.set('duration', dur); - let result = null; try { const r = await fetch(`https://lrclib.net/api/get?${exactP}`, { signal: SIG() }); if(r.status === 200) { @@ -199,8 +213,6 @@ async function _loadNpLyrics(songId) { } catch {} } - if(token !== _lyricsLoadToken) return; - // 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3) if(!result) { const artistP = new URLSearchParams({ track_name: title }); @@ -214,12 +226,15 @@ async function _loadNpLyrics(songId) { result = bestMatch(artistData, dur) ?? bestMatch(titleData, dur); } - if(token !== _lyricsLoadToken) return; - - if(token !== _lyricsLoadToken) return; _lyricsCache.set(cacheKey, result ?? null); - content.innerHTML = ''; - _showLyricsResult(content, result); + return result ?? null; +} + +/** 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) { @@ -268,6 +283,8 @@ export function onNpSongChange(songId) { _lyricsLines = []; const content = document.getElementById('np-lyrics-content'); 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); }