The events dispatcher closed every prior open row as play_skipped on
any track-id change. Server-side RecordPlaySkipped force-sets
was_skipped=true regardless of completion, so a queue played start
to finish reported tracks 1..N-1 as skips — inflating
recommendation skip-ratios (-skipRatio*SkipPenalty) and degrading
For You / Discover quality for web listeners.
Now: on track change, close the prior row as play_ended if that
track reached ~its duration (3s tolerance, matching the Flutter
PlayEventsReporter), else play_skipped at the real last position.
Race fix: the store synchronously resets position/duration to 0 on
track change, so reading lastPositionMs at change-time would see 0
and misclassify. Track per-open-row state (openReachedEnd,
openLastPositionMs, openDurationMs) updated ONLY while the open
track is current — a track change can't clobber them before the
close branch runs.
Brings web wire behavior back in line with Flutter. Test added:
auto-advance after reaching duration → play_ended, never skipped;
existing mid-track-skip and pause-at-end tests still hold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
(rotation-aware order from the server) instead of getPlaylist;
plays the returned order AS-IS — no client Fisher-Yates, since
the server already ordered it. Supersedes #413's client shuffle
for system playlists specifically; user playlists keep getPlaylist
+ stored order.
- tags the queue with the system variant. The player store carries
_queueSource; the events dispatcher includes `source` on
play_started so the server advances that playlist's rotation.
User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.
Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).
Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
03:00 user-local snapshot is what plays now. Stops burning server
compute on every press and stops swapping the playlist out from
under the user.
- Generalize the kebab affordance to render for any system playlist
(was Discover-only). Adds "Refresh For You" as an explicit
replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
the queue when shuffle:true. PlaylistCard passes shuffle:true for
any non-null system_variant.
Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
starting index and enables AudioServiceShuffleMode.all after
setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.
User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.
- preload="metadata" → "auto" on the main audio element so the
browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
events. Schedules a reload-from-current-position after delayMs
(3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
triggers immediate retry. Bounded by maxRetries (3 default) to
prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
resolve(track) → URL + optional prefetch(track) hint. Default
returns track.stream_url; the planned Tauri shell can swap via
setAudioLoader() to return blob URLs backed by a Rust cache,
mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
start loading queue[index+1]. On track-end the swap is gap-free
via browser HTTP cache + audio buffer warm-up.
Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces inline TrackRef literal redefinitions with calls to the
test-utils helper. Tests that asserted on default field values
(e.g. track titles, artist names rendered in the DOM) keep explicit
overrides; tests that only need a stub for shape now use makeTrack()
with no overrides.
PlaylistCard.test.ts, PlaylistTrackRow.test.ts, and playlist.test.ts
SKIPPED — they use PlaylistTrack (with track_id/added_at), not TrackRef.
ArtistCard.svelte and +page.svelte route files (matched by initial grep)
SKIPPED — live code, not test files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three nearly-identical try/catch wrappers across theme + player stores
collapse to read()/write()/remove() in lib/util/safeLocalStorage.ts.
Sets the pattern for future stores. Caller still does parse/serialize
since the existing call sites store strings (theme preference, volume
number) — no JSON wrapper needed yet.
persisted.ts left alone — its JSON-payload + per-key-suffix shape is
distinct enough to keep self-contained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auth/store.svelte.ts no longer imports from $lib/player. Logout
broadcasts via auth/sessionEnd.svelte.ts (a tiny tick + outgoing
userId pair); player subscribes through $effect.root and owns the
player-specific teardown (clearPersistedQueue + playQueue([]) +
closeQueueDrawer()).
Cycle fully inverted — auth is now a pure leaf for player; future
session-end consumers (download cache, recent-search history) plug
in by adding their own effect on the same signal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI test-web was blocking on:
- QueueDrawer.test close-button query: getByLabelText(/close queue/i)
matched BOTH the backdrop button (aria-label="Close queue backdrop")
and the close button (aria-label="Close queue"). Switched to exact
string match.
- QueueDrawer.test inert assertion: Svelte 5 + jsdom uses property
binding for `inert`, not attribute, so hasAttribute('inert') returns
false. Check the DOM property (with attribute fallback for
robustness) instead.
- queue-row-math.ts -0 normalization: Math.round(-0.5) returns -0,
and Object.is(-0, 0) is false — vitest's .toBe(0) fails. Added
`|| 0` to normalize -0 to +0 at the function boundary so consumers
never see -0.
- player/store.svelte.ts user import guard: persistence $effect.root
reads `user.value?.id` at module-init, but in test files where
auth/store.svelte.ts is imported transitively (auth/store.test,
playlists/playlists.test), the player module loads via auth's
import chain before auth's `user` binding is assigned. `user?.value`
guards against that init-order race. The proper fix is to invert
the auth↔player import dep (filed as I2 follow-up under #375).
CI svelte-check failed on 5 type errors caused by the slice using
duration_ms/album_name fields that don't exist on TrackRef (the real
shape uses duration_sec + album_id/album_title). Fixed across
QueueDrawer, QueueDrawer.test, QueueTrackRow.test, persisted.test,
and the inline `state.current = null` in PlayerBar.test (TrackRef |
undefined, not | null).
Final whole-slice review concerns also rolled in:
- C1 (Critical): persisted position was restored to UI but never
seeked into the audio element — first timeupdate snapped _position
back to 0. Added a one-shot _pendingRestorePosition module ref +
consumePendingRestorePosition() export; layout's loadedmetadata
handler now seeks once on restore.
- I1 (Important): throttled-write effect now wraps _queue / _index
reads in untrack so the dependency tracking matches the design
intent. Position remains the only tracked dep.
- I4 (Important): drawer gets inert={!queueDrawerOpen} so its inner
buttons drop out of tab order when closed (aria-hidden alone
doesn't do that). Removed redundant role="complementary" from
<aside> (implied by the element). New test asserts inert binding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap the _position read inside untrack() in the immediate-write $effect
so it no longer registers as a reactive dependency, preventing the effect
from firing on every 4Hz position tick. Also adds three missing
restoreQueue unit tests with vi.mock for the auth store.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inserts at _queue[_index + 1] so the next-up slot is overwritten with
the chosen track. Empty-queue seeding mirrors enqueueTrack's behavior.
Clears _radioSeedId since user-driven enqueue invalidates the M4c
auto-refresh trigger.
Adds _radioSeedId state and a module-level $effect.root that triggers a
/api/radio?seed_track=…&exclude=… fetch when queue consumption reaches
80%, appending new tracks without resetting the radio seed. Clears seed
on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five
new vitest cases cover the trigger threshold, append logic, in-flight
guard, and manual-enqueue reset path.
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.
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.
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.