Player: opening the PWA no longer auto-plays when the user had paused. Pausing via
the media notification while backgrounded routes through togglePlay, which now sets
S._userPaused; _onForeground only auto-resumes an INVOLUNTARY background stop
(_wasPlayingBeforeHide && !_userPaused). playCurrent clears the flag on a new track.
Involuntary-stop/freeze recovery on foreground is preserved.
Stats modal: no more small→big flash when switching period/tab — the body keeps a
stable min-height and retains its current content until the new data arrives (only
the first open shows 'Lade…'); a per-request token drops out-of-order responses.
SW cache -> ns-v71.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1+2 of a self-hosted, multi-user listening archive — the basis for
day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing
Navidrome/Last.fm scrobble; never replaces it.
Service (isolated, own container):
- scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes
POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive),
DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health.
Multi-user auth via Navidrome ping.view (cached) -> user not spoofable.
- docker-compose: novasonic-scrobble service (node:22, port 3710) + volume;
backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example).
Capture (both platforms, offline-safe queue, idempotent batch flush):
- Fires at the existing scrobbleMode counted-play moment — consistent with
Last.fm, respects the user's setting (no separate 50%/4min rule).
- Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams()
in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl.
- Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) +
repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush +
scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button).
Plan + design decisions documented in docs/scrobble-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ping Navidrome with scrobble?submission=false when a track starts so
getNowPlaying reflects it instantly (feeds e.g. a Discord rich-presence
plugin / other clients' Now Playing). Independent of the existing delayed
"played" scrobble used for play counts and Last.fm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NP overlay only refreshed its cover while open, so skipping with it closed
left the <img> on the last-opened song; reopening briefly showed that old cover
until the current one loaded. Now the NP cover element is kept pointed at the
current song even while closed (loads in the background), so opening it always
shows the right artwork.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reintroduce the two-element handoff, this time as a skip-speed optimization
(not for the OS tile). A second <audio> element pre-buffers the next track;
on a forward skip playCurrent hands playback over to it instead of reloading
the source, so playback starts immediately instead of ~1s. Covers are
pre-warmed in parallel so the new artwork is ready too.
Listeners are bound to both elements once (handlers guard with el !== S.audio).
Safety: handoff only when the standby is buffered (readyState >= 2); otherwise
falls back to the unchanged source-swap path, and a failed handoff play()
recovers with a fresh source load. Web-only; the native bridge path is untouched.
Note: this does not change the OS media-tile flicker (a separate, MSE-only fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing the second <audio> element fixed most of the OS media-tile flicker,
but skips now load the next track fresh → a brief gap where Windows shows the
generic site entry. Prefetch the next track's first ~4MB with fetch() (not a
media element, so no Chromium session flip) to warm the HTTP cache + TLS
connection; the src-swap then starts faster and the gap shrinks. Abortable on
queue change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navidrome's player (react-jinke fork) uses one persistent <audio> element and
does not flicker the OS media tile, so the source-swap itself isn't the cause.
Our one structural difference was a second buffering <audio> (_preAudio) for
preload. A second media element seems to make Chromium hand the OS media
session over on skip → Windows tile flicker. This drops it; we rely on the main
element's preload="auto" + browser HTTP cache. Cover art is still prewarmed.
Trade-off: next-track start may be marginally slower. Easy to revert if it
doesn't fix the flicker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a second <audio> element; S.audio always points at the active one and
listeners are bound to both once (handlers guard with el !== S.audio).
preloadNext buffers the next track into the standby element, and playCurrent
hands playback over to it on skip instead of swapping the source — so the
active element never empties and Windows keeps the media tile (also makes
forward skips instant/gapless).
Safety: handoff only when the standby is buffered (readyState >= 2); falls
back to the unchanged source-swap path otherwise, if there's no second
element, or if handoff play() rejects. Web-only — the native bridge path is
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Home rows get hover left/right arrows that only show for the scrollable
direction (enhanceScrollRow + initScrollRows, called from startApp);
visibility recomputed on scroll/resize/content-load.
Fix the OS media tile flicker when skipping on desktop PWA: mutate the
existing MediaMetadata in place instead of assigning a new one each track
(the replacement made Windows tear down and rebuild the tile). Action
handlers are now set once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Space = play/pause, ←/→ = seek 5s, Shift+←/→ = prev/next, ↑/↓ = volume.
Handler lives in player.js (where _pos/_dur/seekToMs are in scope), wired
from startApp via initKeyboardShortcuts(). Ignored while typing in inputs,
and yields Space to focused buttons/links so it never double-fires.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dispAlbumName now takes the full album object and reads album.sortName
directly — no kuromoji fallback, no backend translation.
dispAlbum (song context) also drops the kuromoji fallback, keeping only
the sortAlbum/albumSort file tag.
Override list → Navidrome sort tag → raw Japanese name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The queue serialization was saving artist as a plain string but not the
artists array. On cold start the app restores from localStorage, so
parseArtists had no array to work with and fell back to splitting the
artist string by semicolons — "Maduk Veela" has none, so it returned
one combined entry. PWA always re-fetches fresh API data so the array
was always present. Adding artists:[] to both song and queue entries
fixes artist navigation after cold start on Android.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WebBridge: skip setMediaItems/prepare when the track is already buffered
from the cold-start pre-warm, so resuming the last song is instant (~0s)
instead of re-buffering from scratch (~10s).
Player: show pause icon during buffering (isPlaying=false, isBuffering=true)
so the button doesn't flash back to "play" between tracks. Also optimistically
set _nativePlaying=true and call setPlayIcon immediately on playCurrent so the
icon updates before the native callback round-trip.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Call updateMediaSession immediately on track change so skip buttons and
cover art in the OS notification bar update without waiting for audio load
- Replace link[rel=prefetch] (no-op for audio streams) with a shadow Audio
element (preload=auto) that actually buffers the next track
- Start preloading 60s before track end instead of 30s for more buffer margin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Settings dropdown: 15s (default), 30s, 1/3 of duration, 2/3 of duration, song end.
Duration-based modes use s.duration from Navidrome metadata (always available).
'end' mode skips the timer and fires the scrobble in onEnded (web) / onQueueEnded (native).
Skipping a song never scrobbles in any mode — _cancelScrobble() still runs on skip/pause.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move player.js + nowplaying.js → src/player/
- Move views.js + spotify/soundcloud/youtube/abs → src/views/
- Move ui.js + components.js → src/ui/
- Update all import paths (static + lazy) across every file
- MusicService: wrap ExoPlayer in ForwardingPlayer that always advertises
next/prev commands so the notification skip buttons stay visible in radio
mode even when the JS queue has only one native track
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>