S10.2 — Queue UX: persistent queue + Up Next|History + reorder#54
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
replaceEntries), and on launch the queue is restored PAUSED at the saved track + offset. The now-playing cursor lives inUserDefaults(no schema change → no migration; DUR-1 durability polish stays deferred).Notable engineering
List→ScrollView/LazyVStackfor the queue (and History). AListrow's.dropDestinationnever fires on macOS (forum 730367) and.onMovedrag 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 customUTTypesilently failed drop-matching).movePlaylistItemsre-anchors bothselectedTrackIndexand the gapless on-deckpendingNextIndexby stableQueueItem.id, so reordering a playing queue can't desync audio ⟂ ▶/History ⟂ play-count at the seam.QueueItem.ididentity (a positional.id(index)had caused stale now-playing highlights); play-count writes prefer the durable track id.Color.rowNowPlaying/rowSelected,QueueRowmetrics) — no hardcoded literals in the new styling.make runno longer reports a false failure onopen's benign-600for this app.Review & QA
make strict-gatewas green locally through the reorder work (VerifyLibraryStore 106/106, leak-check, sanitizers). CI re-runs the gate on this PR.Deferred (follow-ups, noted)
LazyVStackhas no cell-recycling for a thousands-long queue (List did).selectedTrackIndexdouble-duty).🤖 Generated with Claude Code