useEventsDispatcher subscribes to player.current/state via \$effect:
on first transition to playing for a new track, POSTs play_started
and caches the id; on track-id change while a play is open, closes
the prior row as play_skipped; on natural completion (state
playing→paused with position >= duration > 0), POSTs play_ended;
on pagehide, navigator.sendBeacon fires a final play_skipped.
Stable per-tab client_id from sessionStorage.
Header grows a flex-1 column between the brand and the user menu.
Existing Shell tests remain green (the search input has no impact on
nav/menu behavior).
Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms
then goto('/search?q=…'); first nav from elsewhere pushes, subsequent
typing on /search replaces. Empty input on /search drops the param.
Escape clears the input. URL → value re-sync via \$effect uses
untrack() on the value comparison so it doesn't loop with typing.
Wraps the existing link in a relative container; adds an absolutely
positioned + button. On click stops propagation, fetches /api/albums/:id
once, and feeds tracks into enqueueTracks.
Outer <button> becomes <div role="button" tabindex="0"> with keyboard
handlers (Enter/Space) so a real <button> for + queue can nest without
invalid HTML. New optional onPlay prop overrides the default
playQueue(tracks, index); used by the search Tracks section to call
playRadio(track.id) instead. + queue button calls enqueueTrack and
stops propagation so the row click does not fire.
createSearchQuery hits /api/search with limit=10 (the main /search
page summary). createSearchArtistsInfiniteQuery /
createSearchAlbumsInfiniteQuery / createSearchTracksInfiniteQuery each
hit /api/search with limit=50 and project to their own facet, used by
the per-facet overflow pages. All four are gated by q.length > 0 so
empty queries don't fire.
enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone.
Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns
in the last row, rendering only when player.current is defined so the
row collapses before first play.
TrackRow becomes a <button> that dispatches playQueue(tracks, index).
Album page supplies the full track list + each index so the rest of
the album enqueues automatically when the user clicks a track.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
useMediaSession() wires navigator.mediaSession metadata + action
handlers (play/pause/previous/next/seekto) + positionState to the
player store via $effect. Exports nothing stateful — callers just
invoke it once at root-layout mount.
toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'.
Core state (queue, index, state, position, duration, volume, shuffle,
repeat, error) plus actions: playQueue, togglePlay, linear skipNext,
skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted),
registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat
logic and audio-event reporting land in follow-up commits.
Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25
exposes a partial localStorage global that lacks getItem/setItem/clear
(requires --localstorage-file with a path), and jsdom defers to the
Node global when present, leaving tests unable to exercise storage.
qk.{artists,artist,album} key generators and create*Query wrappers
with shared page size, sort-aware keys, and infinite-query plumbing
for /api/artists.
The synchronous pre-timeout in the implementation was a band-aid for a
missing flushSync() in the third test. $effect callbacks are scheduled
as microtasks and don't run until flushed — the test set source=true
before the initial effect had a chance to register the setTimeout, so
advanceTimersByTime fired nothing.
Proper fix: call flushSync() after $effect.root so the initial effect
runs synchronously, then advance fake timers. Removed the duplicate
setTimeout branch from useDelayed — one code path, no race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Boolean mirror that flips true only after the source stays true for
delayMs. Used to hide the skeleton on sub-100ms cache hits so
back-navigation feels instant.
Central synchronous source of truth for 'who is signed in'. bootstrap()
runs once in +layout.ts load(); login/logout mutate _user and the
TanStack query cache. The silent option on logout is used by the 401
interceptor (next commit) to avoid POSTing /logout against an already-
invalid session.
vitest's toMatchObject doesn't accept a type parameter; tests passed
under vitest (esbuild transpile) but svelte-check rejected. Plan-spec
bug introduced in 4d1a7f8; fixing here so check is clean before Task 6.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tuned defaults: retry disabled (we throw typed errors), 30s staleTime
to make tab-switch re-navigation feel instant, no refetchOnWindowFocus
(users on a music app don't expect spurious network when they tab back).
Wraps fetch, parses JSON, and throws ApiError{code,message,status}
on non-2xx. Degrades to {code:'unknown'} when the error body is not
the expected envelope shape.