diff --git a/CHANGELOG.md b/CHANGELOG.md index 27750de..661b24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,12 +67,14 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an - **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 - **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome) - **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields +- **Web** — Horizontal rows (Home) now have hover scroll arrows; only the scrollable direction is shown (no left arrow at the start, no right arrow at the end) ### 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 - **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state +- **Web (PWA)** — OS media tile no longer disappears/reappears when skipping tracks on desktop: the Media Session metadata is now mutated in place instead of replaced with a new `MediaMetadata` each track (which made Windows rebuild the tile) ### 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/src/main.js b/src/main.js index 9ff8fed..9c84d7a 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,7 @@ import { toast } from './utils.js'; import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js'; import { injectPWA } from './pwa.js'; import { loadSavedTheme } from './themes.js'; +import { initScrollRows } from './ui/components.js'; import { playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, @@ -221,6 +222,7 @@ window.addEventListener('load', () => { injectPWA(); initProgressScrub(); initKeyboardShortcuts(); + initScrollRows(); // Click player-art to open Now Playing document.getElementById('player-art')?.addEventListener('click', openNowPlaying); diff --git a/src/player/player.js b/src/player/player.js index 8186577..8e59d07 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -1019,23 +1019,38 @@ export function setVolume(v) { // ─── MEDIA SESSION ──────────────────────────────────────────────────────── +let _msHandlersSet = false; export function updateMediaSession(s) { // When native is active, Media3 owns the system notification — don't touch it. if (HAS_BRIDGE) return; - if(!navigator.mediaSession) return; + const ms = navigator.mediaSession; + if(!ms) return; const artwork = s.coverArt ? [ { src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' }, ] : []; - navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); - navigator.mediaSession.setActionHandler('play', togglePlay); - navigator.mediaSession.setActionHandler('pause', togglePlay); - navigator.mediaSession.setActionHandler('previoustrack', prevSong); - navigator.mediaSession.setActionHandler('nexttrack', nextSong); - navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); - navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); - navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); + // Mutate the existing metadata in place rather than assigning a new MediaMetadata — + // replacing the object makes Windows tear down & rebuild the media tile (visible + // flicker when skipping). Spotify-style: keep one object, just update its fields. + if (ms.metadata) { + ms.metadata.title = s.title; + ms.metadata.artist = s.artist || ''; + ms.metadata.album = s.album || ''; + ms.metadata.artwork = artwork; + } else { + ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); + } + // Action handlers are stable — set them once, not on every track change. + if (_msHandlersSet) return; + _msHandlersSet = true; + ms.setActionHandler('play', togglePlay); + ms.setActionHandler('pause', togglePlay); + ms.setActionHandler('previoustrack', prevSong); + ms.setActionHandler('nexttrack', nextSong); + ms.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); + ms.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); + ms.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); } export function syncMediaSessionPosition() { diff --git a/src/ui/components.js b/src/ui/components.js index 9c40a40..96ce7d9 100644 --- a/src/ui/components.js +++ b/src/ui/components.js @@ -103,3 +103,55 @@ export function mkSection(title) { d.innerHTML = `