Skip to content

S10.2 — Queue UX: persistent queue + Up Next|History + reorder#54

Merged
ramith merged 17 commits into
mainfrom
sprint/s10-2-queue-ux
Jul 14, 2026
Merged

S10.2 — Queue UX: persistent queue + Up Next|History + reorder#54
ramith merged 17 commits into
mainfrom
sprint/s10-2-queue-ux

Conversation

@ramith

@ramith ramith commented Jul 14, 2026

Copy link
Copy Markdown
Owner

S10.2 — Queue UX

Builds on S10.1's persistence spine (schema v3 playlists + DAO) to make the play queue a first-class, durable, reorderable surface.

What's in it

  • Persistent queue — the in-memory queue is authoritative for playback; the built-in "current" playlist is a durable mirror. Every edit debounces a full-snapshot resync (replaceEntries), and on launch the queue is restored PAUSED at the saved track + offset. The now-playing cursor lives in UserDefaults (no schema change → no migration; DUR-1 durability polish stays deferred).
  • Up Next|History panel — segmented switch; a session play-history log (append-only, in-memory, survives Clear Queue), newest-first, tap-to-play-now.
  • Clear Queue — immediate (no confirm), keeps History.
  • Reorder — grip-handle drag-and-drop, context-menu Move (Top/Up/Down/Bottom), and keyboard.

Notable engineering

  • ListScrollView/LazyVStack for the queue (and History). A List row's .dropDestination never fires on macOS (forum 730367) and .onMove drag is suppressed by the row tap gesture (FB7367473) — both verified empirically with logs. Reorder now uses a grip .draggable + per-row .dropDestination; the drag payload transfers as a registered string type (an undeclared custom UTType silently failed drop-matching).
  • movePlaylistItems re-anchors both selectedTrackIndex and the gapless on-deck pendingNextIndex by stable QueueItem.id, so reordering a playing queue can't desync audio ⟂ ▶/History ⟂ play-count at the seam.
  • Rows carry a stable QueueItem.id identity (a positional .id(index) had caused stale now-playing highlights); play-count writes prefer the durable track id.
  • Design-system tokens added (Color.rowNowPlaying/rowSelected, QueueRow metrics) — no hardcoded literals in the new styling.
  • make run no longer reports a false failure on open's benign -600 for this app.

Review & QA

  • Increment 2c hardened via a qa-expert break-it pass (cursor-by-id resolve, offset clamp, duration projection, supersede guard).
  • The reorder rewrite reviewed by swiftui-pro + qa-expert; their CRITICAL/HIGH findings (on-deck/selection re-anchor, drop-type, row identity, keyboard focus, VoiceOver actions) are folded in. Founder manual-tested reorder end-to-end.
  • make strict-gate was green locally through the reorder work (VerifyLibraryStore 106/106, leak-check, sanitizers). CI re-runs the gate on this PR.

Deferred (follow-ups, noted)

  • 3b — drag from the Library into the queue (append) — convenience over the existing Play Next / Add to Queue verbs.
  • LazyVStack has no cell-recycling for a thousands-long queue (List did).
  • Arrow-key selection can move the now-playing pointer mid-playback (pre-existing selectedTrackIndex double-duty).

🤖 Generated with Claude Code

ramith added 17 commits July 13, 2026 09:46
Founder decision: make the S10 chunks individual done-done sprints. Sub-numbered
S10.1-S10.5 (not flat S10/S11/...) to avoid renumbering S11-S18 and breaking the
R1/R2/R3-after-S10/S14/S17 anchors.

- sprint-plan.md: S10 row -> five S10.x rows (S10.1 spine 8sp / S10.2 queue UX 6 /
  S10.3 playlists+M3U 9 / S10.4 macOS control 5 / S10.5 browse polish 3). Status,
  critical path, Phase-1 total (~95sp), R1 milestone all updated to S10.1-S10.4.
- s10-queue-playlists-macos-plan.md: reframed chunks -> five sprints (+SP, per-sprint DoD).
- roadmap.md: S10 row relabeled 'runs as S10.1-S10.5'; R1/critical-path anchors updated.
…l gate)

Design phase for S10.1 (first S10 sprint). BA + swift-expert + qa-expert design,
then architect + the-fool gate (GO-WITH-CHANGES). Must-fixes folded in:
- sweepOrphans symmetric fix (gate's top catch): the per-scan orphan sweep must
  spare playlist-referenced tracks too, not just removeRoot — else a partial
  rescan silently CASCADE-drops playlist entries.
- durability: eraseDatabaseOnSchemaChange=false UNIFORM (not DEBUG-only) +
  make reset-db + additive v3 migration + migration-immutability gate check.
- defer builtin 'current' seed out of the v3 migration to a lazy bootstrap at S10.2.
- position = non-contiguous ordering key (reconciled swift-vs-QA contradiction).
- UNIQUE(name) scoped WHERE is_builtin=0.

Pending FOUNDER BRAINSTORM before implementation (per the dev process).
Founder decisions locked:
1. Durability = keep it simple, DEFERRED — eraseDatabaseOnSchemaChange stays true;
   pre-R1 a schema change drops-and-recreates (playlists included). Real durability
   is a new deferred known-issue. (Drops migration-guard/reset-db machinery.)
2. File gone from disk -> removed from playlists (track_id CASCADE; sweep drops).
   removeRoot still spares referenced tracks as loose (locked EP-LIBRARY rule).
3. Queue = built-in 'current' playlist (seeded in v3 migration).
4. Duplicate names PREVENTED (UNIQUE(name) WHERE is_builtin=0 + typed reject).
5. Default name Apple-style ('New Playlist N', lowest-unused).

Ready to implement.
…-1 closure)

Implements the S10.1 store spine (design s10-1-playlist-queue-store-design.md):
- Schema v3: playlists + playlist_entries tables (ordered, own entry id so a track
  can repeat; UNIQUE(name) WHERE is_builtin=0; single-builtin partial index).
  migrateV2toV3 seeds the built-in 'current' queue playlist. eraseOnSchemaChange
  stays true (durability deferred, founder decision §0.1).
- LibraryStore+Playlists: create/untitled(Apple-style lowest-unused)/rename/delete
  (built-in immutable), ordered append/appendEntries/insert-at/remove/removeEntries/
  reorder, addLooseFileToPlaylist (folder_id NULL), idempotent builtin bootstrap.
  Typed PlaylistNameConflict / PlaylistMutationError. Rides GRDB single-writer txns.
- Gate-1 (SEQ-1) CLOSED: unreferencedTrackIDs filters against playlist_entries, so
  removeRoot spares playlist-referenced tracks (kept loose). File-gone -> CASCADE drop.
- VerifyLibraryStore: 14 pl-* checks incl. the Gate-1 keep/sweep pair + same-track-twice
  + reorder + insert-at + remove + loose-add + dup-name-reject + persistence. Updated
  FTS/migration checks + fullMigrator for v3. make gate 96/96; make strict-gate green.

Next: qa-expert + the-fool break-it pass, then founder manual testing + retro.
… R1 gate)

- SEQ-1: Gate 1 CLOSED by S10.1 (unreferencedTrackIDs now filters playlist_entries;
  proven by pl-gate1-* checks). Both SEQ-1 gates now closed.
- DUR-1 (new): playlist durability across schema change is deferred (founder §0.1,
  keep-it-simple pre-release) but flagged as a HARD GATE that MUST be resolved
  before R1 ships playlists — eraseOnSchemaChange + tracks.id churn would corrupt
  playlists. Logged so the deferral isn't forgotten (the-fool tripwire).
…pass

The green gate had false confidence (checks only hit happy paths). Break-it pass found:
- D1 (correctness): reorderPlaylist with a partial list collided positions -> silent
  scramble. Now renumbers the WHOLE playlist (supplied order + omitted appended;
  foreign ids ignored) — like insertEntry.
- D2 (correctness): addLooseFileToPlaylist of an already-library URL re-upserted it,
  nulling folder_id/relative_path + resetting metadata_scanned. Now reuses the
  existing row; only a new URL inserts a loose one.
- D3 (data-loss footgun): removeEntry/removeEntries weren't playlist-scoped -> a
  foreign entry id deleted from another playlist. Added playlistID + AND playlist_id=?.
- D4 (robustness): appending to a deleted/absent playlist leaked a raw GRDB FK error
  -> now typed PlaylistMutationError.notFound (playlist-exists pre-check).
- D5/D6 (robustness): a user playlist named 'current' (any case) and empty/whitespace
  names were accepted -> rejected via validatedUserName + .invalidName.
- D7 (sweepOrphans partial-rescan): confirmed, consistent with locked §0.2; deferred
  (logged as design §5 note + DUR-1).

Added 6 fix-covering pl-* checks (partial-reorder, loose-add-existing, remove-foreign,
append-notfound, name-validation, remove-entries). VerifyLibraryStore 102/102;
make gate + make strict-gate green.
Design phase for S10.2 (Queue UX). BA + swift-expert + swiftui-pro, then architect
+ the-fool gate (GO-WITH-CHANGES). 7 must-fixes folded in:
- History = a separate append-on-completion log, NOT the queue prefix (prefix
  breaks under shuffle/repeat; contradicted the UI SME's own design).
- Mirror = debounced FULL-SNAPSHOT resync (drops entryID + serial chain + patch-back;
  dissolves the reorder-before-append-acks hazard). Never try?-swallow: log + resync.
- Quit must flush the mirror + persist cursor in shutdown() (no applicationWillTerminate).
- Hydration guard (pending/hydrated/superseded) so a late launch-hydrate can't clobber
  a just-started album.
- Dup-identity surface sweep: ForEach id + dedup guard + movePlaylistItems + Info
  popover + onChange all move off URL to QueueItem.id.
- DUR-1 now reaches the queue (durable queue on a self-erasing DB) — surfaced to founder.

Pending FOUNDER BRAINSTORM before implementation.
The gate-provable foundation for the S10.2 persistent queue (design
s10-2-queue-ux-design.md; founder brainstorm locked: persist/restore-paused/
duplicates/session-history):
- replaceEntries(playlistID:trackIDs:) — the debounced full-snapshot primitive
  (clear + append dense 0..n-1, one txn; empty = clear; dup tracks allowed).
- clearEntries(playlistID:) — explicit Clear Queue, one txn.
- incrementPlayCount(id:playedAt:) overload — closes the S9.5 url->id play-count
  seam (the queue carries the durable id).
- 3 new VerifyLibraryStore checks (pl-replace-entries / pl-clear-entries /
  pl-playcount-by-id). VerifyLibraryStore 105/105; make gate + make strict-gate green.

Next increments (VM/queue-model refactor + queue UI) build on these.
The S10.2 store-op checks pushed ChecksPlaylists.swift past the 500-line lint
threshold. Move the D1-D6 break-it fix checks + the S10.2 queue-mirror store-op
checks into ChecksPlaylistsHardening.swift (shared seedTracks/SeededTracks made
internal); wrap one 122-char line. Both files now clean. lint 0 violations;
VerifyLibraryStore 105/105.
…rement 2a)

Behavior-preserving queue type-change + the dup-identity fix (in-memory only;
persistence mirror is 2b):
- New QueueItem{id: UUID, file: AudioFile}: stable per-slot identity decoupled from
  the file URL, so the SAME track can appear more than once in the queue.
- AudioViewModel: stored queue:[QueueItem]; a read-only computed playlist:[AudioFile]
  shim keeps the many cold read consumers unchanged (only write-sites moved).
- +Queue: playNow/playNext/playTrackNextNow/appendToQueue/armOnDeck operate on queue;
  removed the S9 dedupe-on-add guard (duplicates now allowed, per founder brainstorm).
- +Playlist: reorder re-anchors selection by QueueItem.id (dups-safe); remove/clear on queue.
- PlaylistView: ForEach + Info-popover + onChange key on QueueItem.id, not AudioFile URL
  (the dup-identity surface sweep — else duplicate rows crash/collapse).
build + make gate + make strict-gate green (no test assumed dedupe).
…t-by-id (2b)

The queue's persistence write-side (read-side/hydration is 2c):
- AudioFile gains var trackID (defaulted; store-agnostic); AudioFile(LibraryTrackDisplay)
  carries track.id — closes the S9.5 url->id seam.
- AudioViewModel+QueueMirror: scheduleQueueMirror() debounces (250ms, cancel-predecessor)
  a full-SNAPSHOT resync of queue.compactMap(file.trackID) -> replaceEntries(current).
  Errors logged + self-heal on next edit (never try?-swallowed); advance never mirrors.
- Every editing verb (playNow/playNext/playTrackNextNow/appendToQueue/move/remove/clear)
  schedules the mirror.
- Play-tracking now prefers incrementPlayCount(id:) via the slot's trackID, url fallback.
build + make gate + make strict-gate green.
…r (2c)

Read-side of the persistent queue. On launch, when the library store finishes
building, restore the queue from the built-in "current" playlist and RESTORE-
PAUSED at the saved track + offset (never auto-play). On a clean quit, flush the
final queue snapshot and persist the now-playing cursor.

- LibraryModel.onStoreReady: one-shot main-actor hook fired when `store` goes
  non-nil (mirrors onError/onEngineReady). Wired by the composition root
  (AdaptiveSound.swift Edge 3) to AudioViewModel.hydrateQueueOnLaunch.
- AudioViewModel+QueueHydration: hydrateQueueOnLaunch reads currentPlaylistID →
  entries → resolves each trackID to an AudioFile (store.track(id:)), builds the
  [QueueItem], sets it directly (no re-mirror), restores selection/offset via the
  existing pausedResumePosition resume path; shuffle/repeat from UserDefaults.
  persistQueueCursor writes {position, offset, shuffle, repeat} to UserDefaults —
  no schema column, so no migration (DUR-1 unchanged).
- Hydration guard: hasUserEditedQueue (set in scheduleQueueMirror) + queueHydrated
  + empty-queue re-check after the awaits, so a user edit before store-ready
  SUPERSEDES hydration (their queue wins).
- Quit-flush: shutdown() persists the cursor + awaits mirrorQueueNow() BEFORE
  performStop() zeroes the playhead; cancels any pending debounced mirror first.

Build clean; make strict-gate PASSED (VerifyLibraryStore 105/105, leak-check +
plant-a-leak, sanitizers).
qa-expert break-it pass on the persistence core found 7 issues (0 critical).
Fixed the correctness/crash ones; deferred the durability-polish one (DUR-1).

Fixed:
- #1 resume-offset leaks onto the wrong track: arrow-key reselection changed
  selectedTrackIndex without clearing pausedResumePosition, so play() seeked the
  NEW track to the old offset (reachable straight off 2c's restore-paused). Now a
  didSet on selectedTrackIndex clears the resume point on any change to a DIFFERENT
  track — centralizes the 'explicit new-track action clears resume' contract.
- #2 cursor-by-bare-index resumed the wrong track after a between-session delete
  (CASCADE holes the entries). Cursor is now (position, trackID-at-position),
  resolved tolerantly: exact (position,id) match → id-anywhere → start-at-top.
  Dup-safe (position-first). Also makes a stale cursor after a non-clean exit (#3)
  degrade to start-at-top instead of a silent wrong-track/offset resume.
- #4 N+1 hydration: new batched tracksDisplay(ids:) read (one WHERE id IN (…)
  query per chunk) replaces the per-entry track(id:) loop.
- #5a '--:--' relaunch regression: hydration now reuses LibraryTrackDisplay via
  the same AudioFile(_:) adapter the live queue uses → real duration/metadata.
  #5b scrubber shows the resume point (playbackPosition = offset). #5c offset
  clamped to the resolved track duration before the resume seek.
- #6 loose-file queue slots (no trackID) are logged, not silently dropped, by the
  snapshot mirror (latent until a loose-file queue entry point lands).
- #7 repeatMode clamped to 0…2 on restore (a corrupt/skewed UserDefaults value
  would otherwise crash the ['off','all','one'][mode] view subscript).

Deferred:
- #3 periodic (not quit-only) cursor persistence to shrink the crash-divergence
  window = DUR-1 durability polish; the tolerant resolve keeps a stale cursor safe.

VerifyLibraryStore 106/106 (new BR9: tracksDisplay(ids:) dup-collapse / missing-
absent / empty→empty / durationMs projected). make strict-gate PASSED.
…ue (3a)

The queue panel gains a segmented Up Next|History switch:
- Session play-history (AudioViewModel.sessionHistory): append-only log of tracks
  that BEGAN a genuine new play — hooked in startPlayback (resumeFrom == nil, so
  pause/resume doesn't spam it) and the gapless auto-advance seam. In-memory +
  session-scoped: never mirrored/persisted, and Clear Queue leaves it intact
  (founder §3a). HistoryItem carries a stable UUID so replays stay distinct.
- History tab (QueueHistoryList): newest-first, reuses PlaylistItemRow; tapping a
  row plays it NOW via playFromHistory → playTrackNextNow (keeps the rest of the
  queue, moves a first occurrence rather than duplicating).
- Clear Queue button (Up Next only, non-empty): immediate, no confirm (founder §3);
  reuses clearPlaylist (queue + playback), History untouched.
- Header subtitle is mode-aware (N tracks / N played).

Build clean; make strict-gate PASSED (106/106, lint 0, leak-check).
…menu

Manual testing surfaced that queue rows couldn't be reordered by dragging and had
no visible reorder control. Root cause is the documented macOS conflict (FB7367473,
nilcoalescing): a row tap gesture disables .onMove drag — the row's tap-to-play was
eating the drag despite the simultaneousGesture workaround.

Fix (two reliable paths):
- Drag handle: rows are .moveDisabled by DEFAULT (tap-to-play owns the click) and
  drag-reorder re-enables ONLY while the pointer is over a new leading grip handle
  (line.3.horizontal). onHover isn't updated mid-drag, so the row stays draggable
  through to drop. The grip also makes reordering discoverable. PlaylistItemRow
  gains an optional onDragHandleHover (nil for the History list → no handle).
- Context menu: Move to Top / Up / Down / Move to Bottom (disabled at the ends),
  the fully reliable + accessible path. New AudioViewModel move helpers route
  through movePlaylistItems (selection re-anchor by QueueItem.id + queue mirror).

Also: make run no longer reports a false failure — open returns -600
(_LSOpenURLsWithCompletionHandler procNotFound) for this app even on a successful
launch, so the recipe now asserts the process actually came up instead of trusting
open's exit code.

make strict-gate PASSED (106/106).
…chor (3a)

Manual testing showed queue reorder was broken three ways; root-caused with logs
+ swiftui-pro/qa-expert review, then fixed. Native List reorder is impossible here
on macOS 26: .onMove drag is suppressed by the row tap gesture (FB7367473) and a
List row's .dropDestination never fires (Apple forum 730367). So the queue (and
History, for consistency) moved to a ScrollView/LazyVStack with a self-styled row.

Reorder now works via a grip-handle .draggable + per-row .dropDestination, plus the
context-menu Move commands. Fixes from the review + founder test:
- Identity: rows key on the stable QueueItem.id (a stray positional .id(index)
  fought the ForEach key → stale now-playing highlights + rows that didn't refresh
  after a move, so context-menu Moves 'did nothing' visibly). scrollTo uses the id.
- Drag payload: transferred as a registered string type (ProxyRepresentation over
  String). The prior custom UTType(exportedAs:) was undeclared, so drop-type
  matching silently failed and .dropDestination never fired.
- movePlaylistItems re-anchors BOTH selectedTrackIndex and the gapless on-deck
  pendingNextIndex by id (was: selection only, and only when the moved row was the
  selected one) — a reorder while playing no longer desyncs audio ⟂ ▶/History ⟂
  play-count at the seam (qa CRITICAL/HIGH).
- Keyboard: ↑/↓/Return/Space/Delete bound to a focusable queue (List gave key focus
  for free; a row tap sets focus) — FrequencyResponseCanvas pattern.
- a11y: rows expose .accessibilityAction (VoiceOver can't trigger the tap gesture).
- Drop-target border while dragging (isTargeted); count/empty read queue directly
  (not the O(N) playlist shim).

Design system: added Color.rowNowPlaying/rowSelected + a QueueRow metric group
(grip/duration sizing) — no hardcoded literals in the new styling.

Also: make run no longer reports a false failure on open's benign -600.

Deferred (noted): LazyVStack has no cell-recycling for a thousands-long queue;
arrow-key selection can move the now-playing pointer mid-playback (pre-existing
selectedTrackIndex double-duty).

Build clean; make strict-gate PASSED (106/106, leak-check).
# Conflicts:
#	Sources/LibraryStore/LibraryStore+Playlists.swift
#	Sources/VerifyLibraryStore/ChecksPlaylists.swift
@ramith
ramith merged commit 76c3181 into main Jul 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant