From ce648a9425b1bac07d80425075fb3535d677da47 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:04:03 +0530 Subject: [PATCH 01/15] =?UTF-8?q?docs(s10.3):=20=C2=A7Status=20=E2=86=92?= =?UTF-8?q?=20S10.4=20shipped=20(#58);=20S10.3=20kickoff=20(last=20R1=20ga?= =?UTF-8?q?te)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/sprints/s10-queue-playlists-macos-plan.md | 4 ++-- docs/sprints/sprint-plan.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sprints/s10-queue-playlists-macos-plan.md b/docs/sprints/s10-queue-playlists-macos-plan.md index 4911b8c..9d32da1 100644 --- a/docs/sprints/s10-queue-playlists-macos-plan.md +++ b/docs/sprints/s10-queue-playlists-macos-plan.md @@ -1,7 +1,7 @@ # S10 — Queue + playlists + macOS control (sub-sprints S10.1–S10.6) **Document ID:** S10-PLAN-001 -**Status:** S10.1 ✅ + S10.2 ✅ + S10.6 ✅ shipped & merged; **S10.4 in review (PR #58 — gated + break-it-hardened, awaiting founder by-ear/merge) → then S10.3** closes R1. S10 runs as **individual done-done sub-sprints** (S10.1–S10.6), each via the **usual development process** (vetted design → multi-SME review panel → architect + the-fool gate → build-enforced gate + commit). *(Authoritative project status: [sprint-plan.md §Status](sprint-plan.md). Deprioritized 2026-07-14: drag-from-Library-into-queue + M3U/M3U8 import-export.)* +**Status:** S10.1 ✅ + S10.2 ✅ + S10.4 ✅ + S10.6 ✅ shipped & merged (S10.4 = #58); **S10.3 next closes R1.** S10 runs as **individual done-done sub-sprints** (S10.1–S10.6), each via the **usual development process** (vetted design → multi-SME review panel → architect + the-fool gate → build-enforced gate + commit). *(Authoritative project status: [sprint-plan.md §Status](sprint-plan.md). Deprioritized 2026-07-14: drag-from-Library-into-queue + M3U/M3U8 import-export.)* **Relates to:** [sprint-plan.md](sprint-plan.md) — the S10.x sprint series, the last work before **Release R1**. **Depends on:** S8 (library spine, GRDB store) ✅, S9 (browse/search) ✅. @@ -16,7 +16,7 @@ | **S10.1** ✅ | 8 | **Playlist/queue persistence spine** — `playlists` + `playlist_entries` tables (GRDB, delete-rebuild migration; keyed on `tracks.id` with a `position` + own entry id so a track can repeat); DAO for create/rename/delete + ordered add/remove/reorder + loose-file add; the built-in non-deletable **"current"** playlist; `untitled-N` lowest-unused naming; **closes Gate 1** (`unreferencedTrackIDs` gains `AND id NOT IN (SELECT track_id FROM playlist_entries)`). Gated by `VerifyLibraryStore`. | US-PLIST-01, -05, -06 (store), -07; known-issues **SEQ-1 Gate 1** | S8.1 store | | **S10.2** ✅ | 6 | **Queue UX** — persistent play queue (the "current" playlist; survives quit/relaunch, restore-paused); Up Next|History + session history; Clear Queue; reorder (grip-drag + context-menu + keyboard); wires to `PlaybackQueueKit`. *(drag-from-Library-into-queue deprioritized 2026-07-14 — Play Next / Add to Queue verbs cover add.)* | US-PLIST-06 (UI), US-PLAY reorder/history | S10.1 | | **S10.3** | 6 | **Playlists UX** — playlist browse/sidebar (create/edit/rename/delete, scales to hundreds); add songs → playlist as a **reference-add, never a file move** (separate handler from folder-move); add a single non-library file. *(M3U/M3U8 import-export deprioritized 2026-07-14.)* | US-PLIST-02, -03, -04; + the US-PLIST-08 cross-seam test | S10.1 (+ S8.4 ✅ for -08) | -| **S10.4** 🔨 | 5 | **macOS system control** — media keys + Now-Playing / Control Center (`MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the footer/mini-player metadata fix (same resolver). Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). Independent of the persistence spine. | (new — trace to sprint-plan S10.4) | S9 | +| **S10.4** ✅ | 5 | **macOS system control** — media keys + Now-Playing / Control Center (`MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the footer/mini-player metadata fix (same resolver) + loose-file embedded tags (D3 reversed). Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). *(Shipped #58.)* | (new — trace to sprint-plan S10.4) | S9 | | **S10.5** | 3 | **Browse polish** — folder-browse mode; the **deferred A–Z jump rail** from S9. *(Polish — not an R1 gate.)* | (S9 carry-overs) | S9 | | **S10.6** ✅ | 5 | **Recently Played (frecency)** — rework the S10.2 History tab into an all-time, per-track, **frecency-ranked** "Recently Played": persisted play count (a play = **≥60% heard, ~4-min cap**, cumulative), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + `RecentlyPlayedRow`. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(Added 2026-07-14; enhancement, not an R1 gate. Shipped #57.)* | US-PLAY-10 | S10.2 | diff --git a/docs/sprints/sprint-plan.md b/docs/sprints/sprint-plan.md index 05cbc30..e3a07ac 100644 --- a/docs/sprints/sprint-plan.md +++ b/docs/sprints/sprint-plan.md @@ -49,7 +49,7 @@ The rationale is sound: **an audiophile player lives or dies on library + playba | **S10.1** ✅ | Playlist/queue persistence spine | 8 | `playlists` + `playlist_entries` tables (GRDB, keyed on `tracks.id` + position); create/rename/delete + ordered-membership DAO; built-in non-deletable **"current"** queue playlist; `untitled-N` naming. **Closes Gate 1** (SEQ-1). `VerifyLibraryStore`-gated. | S8 | | **S10.2** ✅ | Queue UX | 6 | Persistent play queue (the "current" playlist; survives quit/relaunch, restore-paused); Up Next|History + session history; Clear Queue; reorder via grip-drag + context-menu + keyboard. *(drag-from-Library-into-queue deprioritized 2026-07-14.)* | S10.1 | | **S10.3** | Playlists UX | 6 | Playlist browse/edit (create/rename/delete, scales to hundreds); add songs → playlist = **reference-add** (never a file move); add a single non-library file; the US-PLIST-08 move-survival seam test. *(M3U/M3U8 import-export deprioritized 2026-07-14.)* | S10.1 | -| **S10.4** 🔨 | macOS system control | 5 | **Media keys + Now-Playing/Control Center** (`MPNowPlayingInfoCenter` / `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the on-screen footer/mini-player metadata fix (same resolver). Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). *(independent of the store)* | S9 | +| **S10.4** ✅ | macOS system control | 5 | **Media keys + Now-Playing/Control Center** (`MPNowPlayingInfoCenter` / `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the on-screen footer/mini-player metadata fix (same resolver) + loose-file embedded tags. Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). *(Shipped #58.)* | S9 | | **S10.5** | Browse polish | 3 | Folder-browse mode; the deferred **A–Z jump rail** from S9. *(polish — not an R1 gate)* | S9 | | **S10.6** ✅ | Recently Played (frecency) | 5 | Rework the S10.2 History tab → all-time, per-track, **frecency-ranked** "Recently Played": play count persisted (a play = **≥60% heard, ~4-min cap**), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + row. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(US-PLAY-10; enhancement, not an R1 gate. Shipped #57.)* | S10.2 | From 2916a1d195671138f95731970848d99c080c93b6 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:14:25 +0530 Subject: [PATCH 02/15] =?UTF-8?q?docs(s10.3):=20design=20rev.2=20=E2=80=94?= =?UTF-8?q?=20SME=20+=20Fool=20pre-impl=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in architect-reviewer / swiftui-pro / qa-expert / the-Fool review: - Corrects false premises: no scan-folder drop target exists (folders via open-panel; US-LIB-07 move-on-drag deferred) → reference-add safety is type-level (LibraryTrackDragItem{trackID:Int64} + declared UTType, drop rejects .fileURL); FacetTrackListView is List(selection:) (drop/reorder dead on macOS 26) → detail body reuses the queue's LazyVStack/PlaylistItemRow, header only from the facet view; QueueDragItem carries a queue UUID not tracks.id. - Separate PlaylistsModel peer (not extend the read-only browse cache); epoch-guarded re-read + libraryRevision refresh for CASCADE shrink. - Built-in 'current' filtered from every surface + DAO-guarded append/insert/ reorder (was unguarded → queue-mirror corruption). - Add-to-playlist = context menu (New + recent) → searchable sheet (submenu doesn't scale); context-menu is the primary Songs path (Table drag is a refactor). - QA: US-PLIST-04 proven at the app layer (pure PlaylistDropRouter + FileMover spy + strict-gate grep; DAO test is vacuous), US-PLIST-08 via the real scanner, + reorder-isolation / explain-plan / write-during-scan checks. - 4 open founder-brainstorm decisions (IA, play-semantics, delete-UX, loose-file R1 scope); estimate flagged as likely light. --- docs/sprints/s10-3-playlists-ux-design.md | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/sprints/s10-3-playlists-ux-design.md diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md new file mode 100644 index 0000000..00d0f3d --- /dev/null +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -0,0 +1,61 @@ +# S10.3 — Playlists UX — design (rev. 2 — post SME + Fool gate) + +Last R1 gate. Stories: **US-PLIST-02, -03, -04** (+ the **US-PLIST-08** cross-seam test). M3U/M3U8 import-export **deprioritized** (2026-07-14). Builds on the S10.1 spine. Rev.2 folds in the architect-reviewer / swiftui-pro / qa-expert / the-Fool pre-impl review. + +## 0. Key finding — the store is already built +S10.1 shipped the **entire playlist store DAO** (`LibraryStore+Playlists.swift`): `Playlist`/`PlaylistEntry`/`PlaylistNameConflict`; create/untitled/rename/delete; `playlists()`/`entries(inPlaylist:)`; `appendEntry`/`appendEntries`/`insertEntry`/`removeEntry`(playlist-scoped)/`reorderPlaylist`(full-renumber)/`addLooseFileToPlaylist`(reuses an existing row); built-in "current" bootstrap; validated names; `ON DELETE CASCADE`. 22 headless checks already gate it. Both "decisions to lock" are **decided in code**: duplicate name → **reject** (`PlaylistNameConflict`, UNIQUE backstop); untitled → **lowest-unused** "New Playlist [N]". **So S10.3 is a UI + model-layer sprint** — no schema change, no DAO redesign. + +### 0.1 Reviewer-corrected premises (things my rev.1 got wrong) +- **There is NO scan-folder drop target in the app.** Folders are added via `NSOpenPanel`/`.fileImporter` (`LibrarySidebar`/`MusicFoldersAccordionContent`), and the folder→folder *file-move-on-drag* (US-LIB-07) is **deferred/unbuilt**. So "keep the playlist drop distinct from the folder-move drop" defends a sibling that doesn't exist. → The safety story is **type-level** (§2), not handler-discipline. +- **`FacetTrackListView`/`AlbumDetailView` are `List(selection:)`** — `.dropDestination` never fires + gestures race there (macOS 26). They **cannot** back a reorderable/droppable detail. → Detail body reuses the **queue** list (§3). +- **`QueueDragItem` carries a queue-row UUID**, not the durable `tracks.id`. → A new drag payload is needed (§3). +- **`playlists()` returns the built-in first, unfiltered**, and its `append`/`insert`/`reorder`/`replace` verbs are **unguarded** for the built-in. → Filter + guard everywhere (§2). + +## 1. IA (founder-brainstorm decision — see §7.1) +**Recommendation: a 5th `LibraryCategory`** — Songs · Albums · Artists · Genres · **Playlists** — inside the Library tab. Mechanically a clean extension of the browse machinery (add `.playlists` to `LibraryCategory`; a `.playlist(Int64)` case to `LibraryRoute`; slots in `LibraryCategoryRoot`; `selectedCategory.didSet` clears `path` already). Nav state stays on `LibraryBrowseModel` (survives the tab `switch` teardown). +**Coherence caveat (the Fool):** playlists are user-*mutable*, the other four categories are read-only derived facets — one rail now mixes two mental models. Mitigations: playlists get their own affordances (create/rename/delete/drop), the built-in is hidden, and there is no manual playlist reordering. Rejected alternatives: a 5th top-level tab (overkill); a Finder-style sidebar rework (too big for R1). + +## 2. Architecture +- **`PlaylistsModel` — a NEW `@MainActor @Observable` peer** (NOT an extension of `LibraryBrowseModel`, whose invariant is "read-only cache refreshed by scan-revision"). Owned in the composition root like `browse`, injected via `.environment`; composes `library` (store) + `audio` (queue verbs). Holds `list`, open `detail` (entries), and the mutating verbs. **Nav** (`selectedCategory == .playlists`, `LibraryRoute.playlist(id)`) stays on `LibraryBrowseModel`; playlist views read *nav* from browse + *data* from `PlaylistsModel`. + - **Refresh = authoritative re-read, epoch-guarded** (newest-wins token, mirroring `LibraryBrowseModel.loadAlbums`) after each mutation — a slow pre-mutation `playlists()` must not resurrect a deleted row. Small N → cost nil; `entryCount` is a SQL subquery + untitled/conflict aren't client-predictable, so re-read, not optimistic. + - **Also refresh on `library.libraryRevision`** — a scan reconcile can CASCADE-shrink a playlist (member track swept), changing counts/open detail with no playlist-side action. +- **Built-in "current" is INVISIBLE + INERT to playlists UI.** Filter `is_builtin == 0` in every surface (browse list, add-to-playlist targets, drop targets) via a pure `PlaylistBrowseVisibility.isUserVisible`. Belt-and-braces: reject `append`/`insert`/`reorder`/`replace` on `is_builtin` in the DAO too, so a UI leak degrades to an error, never silent queue-mirror corruption. +- **Reference-add safety is TYPE-LEVEL (US-PLIST-04).** The library-track drag payload is `LibraryTrackDragItem { trackID: Int64 }` (a *declared* UTType, §3). The add handler is `addTrackToPlaylist(trackID: Int64, playlistID:)` → `store.appendEntry` only — **you cannot move a file you know only by integer id**, a compile-time guarantee. Loose add (`addLooseFileToPlaylist(url:)`) reads tags + writes a row; it is read-only w.r.t. the file by construction (no `FileManager` move/copy anywhere in the playlist path — verified). Extract the drop routing into a pure `PlaylistDropRouter.route(payloads) -> [.referenceAdd(Int64)] | [.looseAdd(URL)]` (structurally cannot return a move) for testability. +- **Loose-file lifecycle (US-PLIST-02) — see §7.4.** A `folder_id=NULL` entry can point at a file that moves/deletes externally (not scan-monitored). Three distinct cases, to document + handle: (a) file deleted **and** its row swept by reconcile → entry CASCADE-drops (only for scan-folder tracks; loose rows are never swept); (b) loose file gone, row **not** swept → resolves at play → must **skip + flag**, not halt; (c) moved + re-matched (scan-folder) → survives (US-PLIST-08). Add an orphan sweep for unreferenced loose rows. + +## 3. UI components (reuse map — corrected) +| Piece | Reuse / build | +|---|---| +| Playlists **list** (hundreds) | **`LazyVStack` of plain-Button rows** (AlbumGrid/Artists scaffolding + queue list), each row **also a `.dropDestination(for: LibraryTrackDragItem)`**; name + entry-count + inline rename + context menu. NOT `List(selection:)`. Fixed order (`is_builtin DESC, name`), so no grip/reorder. | +| Playlist **detail** body | **the queue's `PlaylistItemList`/`PlaylistItemRow`** (`LazyVStack` + grip `.draggable` + row `.dropDestination` + ↑/↓/Return/Delete) — the only reorder/drop-capable list here. Reuse **only the header** (title · "N songs · duration" · Play/Shuffle/More) from `FacetTrackListView`. `ForEach` keyed on **`PlaylistEntry.id`** (dupes allowed). | +| Two coexisting drops on detail | **two declared UTTypes** (`Info.plist UTExportedTypeDeclarations`): `…playlist-entry` (reorder) + `…library-track` (add). Two `.dropDestination(for:)`, each its own `isTargeted:` visual (insertion line vs container accent). Accept **only** internal types — reject `.fileURL`/`.audio` so a Finder drop never activates. (The String-`ProxyRepresentation` shortcut can't disambiguate two drops — both would register `public.utf8-plain-text`.) | +| Drag source | new `LibraryTrackDragItem(trackID: Int64)` on library rows (durable id, not the queue UUID). | +| "Add to Playlist" | **context menu**: `New Playlist…` + **top-N recent** playlists + `Add to Playlist…` → a **searchable sheet/popover** (`LazyVStack` filtered by `LibraryFilterField`). A flat all-playlists submenu doesn't scale + menus can't host search. Primary Songs path (operates on the multi-selection via `SongsRowResolver.orderedSelection` — no `Table` drag refactor). | +| Create / inline rename | `TextField` that **sets `KeyboardTransportFocus.isTextEntryFocused`** (else Space toggles playback); editing row tracked in **parent `@State` `editingID: Int64?`** (LazyVStack recycles rows); surface `PlaylistNameConflict` inline (red caption, stay in edit, Esc reverts); gate rename/delete off the built-in in the UI. | +| Play a playlist (§7.2) | resolve entries → `AudioFile` via the batched `tracksDisplay(ids:)` + `AudioFile(_:)` adapter (the **same seam queue hydration uses** — no N+1), then the existing queue-load verb. | +| Artwork in detail | share the **existing `ArtworkThumbnailStore`** (inject `browse.artworkImage` as a closure — the `NowPlayingController` precedent), not a second store. | +| Errors | existing `onError` → shell banner. | + +## 4. US-PLIST-08 — cross-seam test (real scanner, not a stub) +Extend the existing `checkMoveReferenceSurvives` scaffold (`ChecksMoveMatch.swift`) — real `ScanFixtureBuilder.writeFile` → real `LibraryScanner().scan` → capture id → real `FileManager.moveItem` → real re-scan. Swap its play_count/loved proxy for a **real playlist entry**: `createPlaylist` → `appendEntry(trackID:)` → move → re-scan → assert (a) entry `trackID` unchanged, (b) it now resolves to the **new** url/folder, (c) `entryCount` unchanged. Cover **both** same-root subdir move and cross-root move. + +## 5. QA plan (rev.2) +- **Pure (`swift test`, `LibraryBrowseKit`):** `PlaylistDropRouter` (playlist drop yields only add-ops, never a move — the real US-PLIST-04 proof); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → "Already in Playlist" toast vs add; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.fileNotFound` (retain) not `.drop`. +- **Strict-gate structural guard:** grep/semgrep that the playlist-drop handler file references no `moveItem`/`copyItem` (locks the "no shared move path" invariant a runtime test can't express). Drop the vacuous DAO "zero-fs-effect" check. +- **VerifyLibraryStore (headless, new):** `pl-move-membership-survives` (§4, real scanner); `pl-reorder-isolation` (same track in A & B, reorder A, B byte-identical + `tracks` rows untouched); `pl-explain-plan` (the `playlists()` correlated-COUNT + `entries()` use their indexes — plan **shape**, no ms budget); `pl-write-during-scan` (append/reorder concurrent with `scan`, incl. member swept mid-reorder → dense positions, no orphan/dup); built-in mutation rejected. +- **qa-expert + the-Fool break-it** post-impl (top attacks): boundary/wrong-target drop; loose→scanned identity then move; delete the open-detail playlist; rename race; reorder during sweep; duplicate-flood renumber+perf; hundreds of playlists (list + query plan); play a moved file (skip + retain, not halt); cross-playlist drag = **copy not move**; built-in never exposed. +- **Founder by-hand:** create/rename/delete; drag Songs→playlist (verify on disk **no file moved**); add a loose file; reorder; play a playlist; membership after a folder move; the drop affordances. + +## 6. Chunking (per-chunk build-gated loop) +- **A** — `PlaylistsModel` peer (epoch-guarded, revision-refresh) + `.playlists` category + browse **list** (LazyVStack rows, built-in filtered); seed rows to verify. `PlaylistBrowseVisibility` test. +- **B** — Playlist **detail** on the queue list body + header; play/remove/reorder; create/inline-rename/delete UX (focus-gate, inline conflict, built-in gated). `ForEach` on entry id. +- **C** — Add-to-playlist: context menu (New + recent) + searchable sheet (US-PLIST-02 incl. single loose file) + `LibraryTrackDragItem` declared UTType + drop-onto-playlist-row / detail add-drop (US-PLIST-03/04). **`PlaylistDropRouter` + `FileMover`-spy + strict-gate grep land HERE with the handler**, not later. +- **D** — US-PLIST-08 real-scanner check + `pl-reorder-isolation`/`pl-explain-plan`/`pl-write-during-scan` + loose-file skip/badge/GC (per §7.4) + strict-gate. + +## 7. Open founder-brainstorm decisions (recommendation first) +1. **IA placement** — 5th Library category *(recommended; mechanically clean, all reviewers)* vs top-level tab vs sidebar rework. Coherence caveat in §1. +2. **Play a playlist** — **replace the queue + a "Restore previous queue" undo toast** *(recommended)* vs append-to-queue vs replace-no-undo. (Replace silently vaporizes a curated queue *and* rewrites the queue-mirror — the Fool's FN-4; undo makes it safe.) +3. **Delete a playlist** — **undo toast** *(recommended — reversibility beats a speed-bump for a curation artifact)* vs confirm dialog. (Revised from rev.1's "confirm" per the Fool.) +4. **Loose-file degradation for R1** — **skip past an unreachable entry + a per-row "unavailable" badge + "Remove missing files" + launch orphan-sweep** *(recommended — the honest US-PLIST-02; a dead file must not brick a playthrough)* vs minimal (halt with a clear error, defer skip/GC). This is real added scope. + +**Estimate note:** several rev.1 "reuses" are actually net-new (the `LibraryTrackDragItem` + declared UTTypes/Info.plist, the searchable add-sheet, play-skip behavior, orphan GC). The 6 SP estimate is likely light; flag at planning. From 84497392b22b2d56b648f10f16ef680bb760d46f Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:42:03 +0530 Subject: [PATCH 03/15] docs(s10.3): market-research memo (cited) on Playlists UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-research pass across Roon/MusicBee/Audirvana/foobar2000 + Apple Music/ Spotify (19 sources → 25 claims 3-vote-verified → 23 confirmed). Key shifts for the design: IA → a dedicated sidebar Playlists SECTION (not a 5th browse category); reference-add-only + hidden current-queue + skip-on-play for dead files all validated by category norms; verb set Play/Play Next/Add to Queue; undo for destructive queue-replace is a differentiation opening (absent in comparables). New scope questions: playlist folders/nesting + smart playlists are category norms currently out of scope. --- docs/sprints/s10-3-playlists-ux-research.md | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/sprints/s10-3-playlists-ux-research.md diff --git a/docs/sprints/s10-3-playlists-ux-research.md b/docs/sprints/s10-3-playlists-ux-research.md new file mode 100644 index 0000000..5efbb73 --- /dev/null +++ b/docs/sprints/s10-3-playlists-ux-research.md @@ -0,0 +1,35 @@ +# S10.3 Playlists UX — market research (cited) + +Deep-research pass (2026-07-15) across audiophile/local-library players (Roon, MusicBee, Audirvana, foobar2000) + mainstream references (Apple Music/iTunes, Spotify). 19 sources fetched → 73 claims → 25 verified (3-vote adversarial) → **23 confirmed, 2 refuted**. This memo grounds the S10.3 design; the team folds it in before locking. + +## Per-axis findings (dominant pattern · divergences · confidence) + +1. **IA / placement — HIGH.** Dominant: a **dedicated left-sidebar Playlists section, SEPARATE from library browse** (Songs/Albums/Artists/Genres), *not* mixed into the browse categories (Roon, MusicBee "Playlists node", Apple Music, Audirvana). → **Shifts our design: a distinct sidebar section beats a "5th browse category."** Also validates the Fool's coherence concern (mutable curation ≠ read-only facet). [roonlabs blog; apple support; musicbee wiki; audirvana forum] +2. **IA / hierarchy — HIGH.** **Playlist folders with nesting** are a category norm (Roon, Apple Music, MusicBee — MusicBee mirrors them to real disk folders). **Currently OUT of S10.3 scope → strongest candidate to reconsider** (or design the schema so a parent-folder id can be added later). [roonlabs; apple; musicbee] +3. **Membership semantics — HIGH.** Universally **reference-based (pointer); NO researched local player moves a file on playlist-add.** The only copy behavior is iTunes/Apple Music's *separate* library-import "copy to media folder" (copies, never moves; original untouched). → **Fully validates our hard "add never moves a file" requirement.** [musicbee wiki; apple discussions] +4. **Add-to-playlist affordance — HIGH.** Dominant = **context menu / 3-dots "Add to Playlist"** everywhere; drag-and-drop is secondary (**Roon reserves drag for REORDER, not add**). Scaling: no confirmed in-picker search precedent, but the **failure mode is documented** — Audirvana's sidebar shows only ~2 playlists → users call it "almost a dealbreaker." → **Sidebar must scroll/show all; a searchable picker for many playlists; a flat all-playlists submenu won't scale.** [roon KB; audirvana forum] +5. **Play semantics — HIGH.** Playlist and queue are **distinct**; playing a playlist **feeds the single active queue**, it is not itself the playback surface. Mature players use **verb-based actions with an explicit destructive-vs-non-destructive split**: Roon *Play Now / Add Next / Add to Queue*; Apple *Play Next / Play Last*. → **Validates our hidden current-queue-as-playback-surface; expose Play (replace) + Play Next (insert) + Add to Queue (append).** [roon KB "the queue"; apple support; roon community] +6. **Play pitfall + a differentiation opening — MEDIUM.** A recurring real frustration: the **default play is destructive (replaces the queue) and over-enqueues**, destroying a hand-built queue (Roon staff-confirmed complaint). **No comparable was found to offer an undo / "restore previous queue."** → Make queue-replace **explicit + reversible (undo toast)** — a genuine differentiator, and it matches the earlier design recommendation. [roon community x2; howtogeek] +7. **Reorder within a playlist — LOW (thin).** Drag-handle model (Roon drag = reorder-only). Aligns with our planned custom `LazyVStack` + grip `.draggable` (macOS SwiftUI `List` can't row-drop). [roon KB] +8. **Delete / remove reversibility — HIGH.** Players **carefully distinguish "remove from playlist" (drops the pointer) from "delete the file"** — MusicBee relabels Delete→Remove inside a playlist and *prompts* which is meant. → **Remove must be non-destructive to file+library by default, clearly labeled; undo toast is a low-risk safety win; any file-delete path gated behind a distinct confirm.** [musicbee wiki; apple discussions] +9. **Missing / dead files — HIGH (crucial for a local player).** Dead entries get a **visible indicator (MusicBee faded + `(!)`; iTunes `!`), are NEVER silently self-healed, and playing errors/skips (not halts)**; players provide **Locate + bulk relink/remap + bulk "remove dead links"** cleanup. → **Mark unavailable entries, SKIP-on-play, offer Locate + "remove missing" + (bulk relink later).** Well-precedented + expected. [musicbee wiki + Relink page; apple discussions] +10. **Smart / auto (rules-based) playlists — HIGH.** A **first-class, near-universal norm** (~10 apps: MusicBee, Audirvana, Roon, foobar2000, Plex, iTunes/Apple, JRiver, Swinsian, Kodi; only VOX lacks). **OUT of S10.3 scope → a real but defensible-for-now gap;** frame as planned-later, keep schema amenable to a future rules table. [musicbee wiki] + +## Unverified / open (no surviving evidence — treat as open) +- **Duplicates (axis 6):** whether apps allow the same track twice / warn / dedupe — unverified. (Our store already *allows* dupes by design; iTunes/MusicBee believed to allow, unconfirmed here.) +- **Naming (axis 10):** duplicate-name reject vs auto-suffix; default/untitled names — unverified. (Our store already decided: **reject** + lowest-unused "New Playlist [N]".) +- **In-picker search / recents / pinning** at scale — only the failure mode (Audirvana truncation) is evidenced, not the positive patterns. +- **Undo/restore-previous-queue** — none found; likely genuinely absent (→ our differentiation opening). + +## Refuted (do NOT rely on) +- "Roon playlists sit at the bottom with no folders" (0-3 — Roon *does* have folders + a redesigned sortable section). +- "Spotify: selecting a new song replaces the queue" (1-2, unsettled). + +## What the research changes vs design rev.2 +- **IA (biggest shift):** move from "5th browse category" → a **dedicated Playlists section in the existing left `LibrarySidebar`** (its own section, separate from the Songs/Albums/Artists/Genres category rail and the Music Folders accordion). Matches the category norm *and* resolves the Fool's coherence concern. Sidebar section must scroll. +- **Confirms unchanged:** reference-add-only (type-level), hidden current-queue, custom LazyVStack drag, skip-on-play for dead files, undo for destructive queue-replace + delete. +- **New scope questions for the founder:** playlist **folders/nesting** and **smart/auto playlists** are category norms currently out of scope — keep out for R1 (defensible) but make the schema forward-compatible, or pull one in. +- **Verb set:** adopt Play (replace, reversible) / Play Next / Add to Queue for "playing" a playlist. + +## Sources (quality) +Primary: roonlabs blog + KB (playlist-enhancements, creating-playlists, the-queue), apple support (playlist folders, queue). Forum/community (real friction): audirvana community (sidebar truncation), roon community (Play Now/Add Next/Add to Queue; destructive-replace complaint), apple discussions (missing-file `!`, copy-on-import, remove-vs-delete). Secondary: MusicBee Fandom wiki (Playlists, Relink Music File Paths) — corroborated by the official getmusicbee.com forum (WebFetch was 402/403-blocked on fandom + getmusicbee, so those leaned on search-snippet extraction). Blog: howtogeek (Spotify queue). From d327371a9f56b4ccc5c5ad5c97f5ca3ffc3dd0db Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:58:36 +0530 Subject: [PATCH 04/15] =?UTF-8?q?docs(s10.3):=20design=20rev.3=20=E2=80=94?= =?UTF-8?q?=20research-grounded=20+=20scope=20expanded=20(folders=20+=20sm?= =?UTF-8?q?art=20playlists)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder scope calls post-research: sidebar Playlists SECTION (not 5th browse category); playlist folders/nesting; smart (rules-based) playlists; Play/Play Next/Add-to-Queue verb set with a 'restore previous queue' undo. Folders + smart playlists are net-new schema + DAO (S10.1 covered static only): playlist_folders self-ref tree + playlists.folder_id; playlists.is_smart + a normalized playlist_rules table with a pure rule→SQL WHERE builder; smart membership is derived, read-only. Reference-add stays type-level; built-in hidden+guarded; dead files skip-on-play + indicator + remove-missing. FLAGGED: (1) re-estimate ~15-18 SP (was 6) → phased chunks A-H, R1 can gate on static+folders+IA with smart as fast-follow; (2) DATA-LOSS RISK — user playlists/folders/rules are NOT a rebuildable cache, so a schema bump under eraseDatabaseOnSchemaChange would wipe them; §9.5 decision needed before the schema bump lands. 5 open founder-brainstorm sub-decisions in §9. --- docs/sprints/s10-3-playlists-ux-design.md | 120 ++++++++++++---------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md index 00d0f3d..03a49f9 100644 --- a/docs/sprints/s10-3-playlists-ux-design.md +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -1,61 +1,75 @@ -# S10.3 — Playlists UX — design (rev. 2 — post SME + Fool gate) +# S10.3 — Playlists UX — design (rev. 3 — research-grounded, scope expanded) -Last R1 gate. Stories: **US-PLIST-02, -03, -04** (+ the **US-PLIST-08** cross-seam test). M3U/M3U8 import-export **deprioritized** (2026-07-14). Builds on the S10.1 spine. Rev.2 folds in the architect-reviewer / swiftui-pro / qa-expert / the-Fool pre-impl review. +Last R1 gate. Founder expanded scope (2026-07-15, post market research — see [research memo](s10-3-playlists-ux-research.md)) to match category norms: **sidebar Playlists section + folders/nesting + smart (rules-based) playlists + Play/Next/Queue verb set with undo.** Stories: US-PLIST-02, -03, -04, -08 **+ new: playlist folders, smart playlists.** M3U import-export still deprioritized. -## 0. Key finding — the store is already built -S10.1 shipped the **entire playlist store DAO** (`LibraryStore+Playlists.swift`): `Playlist`/`PlaylistEntry`/`PlaylistNameConflict`; create/untitled/rename/delete; `playlists()`/`entries(inPlaylist:)`; `appendEntry`/`appendEntries`/`insertEntry`/`removeEntry`(playlist-scoped)/`reorderPlaylist`(full-renumber)/`addLooseFileToPlaylist`(reuses an existing row); built-in "current" bootstrap; validated names; `ON DELETE CASCADE`. 22 headless checks already gate it. Both "decisions to lock" are **decided in code**: duplicate name → **reject** (`PlaylistNameConflict`, UNIQUE backstop); untitled → **lowest-unused** "New Playlist [N]". **So S10.3 is a UI + model-layer sprint** — no schema change, no DAO redesign. +> **Re-estimate:** rev.1/2 was ~6 SP (UI over the existing static DAO). Folders + smart playlists are **net-new schema + DAO + UI** — realistically **~15–18 SP**. Recommend delivering as phased chunks (A–H, §8), each independently gated; R1 can gate on the static+folders+IA core (A–E) with smart playlists (F–G) as a fast follow if timeline pressure appears. Flagging, not relitigating — the scope calls are locked. -### 0.1 Reviewer-corrected premises (things my rev.1 got wrong) -- **There is NO scan-folder drop target in the app.** Folders are added via `NSOpenPanel`/`.fileImporter` (`LibrarySidebar`/`MusicFoldersAccordionContent`), and the folder→folder *file-move-on-drag* (US-LIB-07) is **deferred/unbuilt**. So "keep the playlist drop distinct from the folder-move drop" defends a sibling that doesn't exist. → The safety story is **type-level** (§2), not handler-discipline. -- **`FacetTrackListView`/`AlbumDetailView` are `List(selection:)`** — `.dropDestination` never fires + gestures race there (macOS 26). They **cannot** back a reorderable/droppable detail. → Detail body reuses the **queue** list (§3). -- **`QueueDragItem` carries a queue-row UUID**, not the durable `tracks.id`. → A new drag payload is needed (§3). -- **`playlists()` returns the built-in first, unfiltered**, and its `append`/`insert`/`reorder`/`replace` verbs are **unguarded** for the built-in. → Filter + guard everywhere (§2). +## 0. Store status +S10.1 built the **static-playlist DAO** (create/rename/delete/append/insert/remove/reorder/loose-add, built-in "current", name-conflict, CASCADE; 22 headless checks). **NET-NEW for rev.3:** (a) **playlist folders** (hierarchy) and (b) **smart playlists** (rules + evaluation) — both need schema + DAO. Confirmed absent today. -## 1. IA (founder-brainstorm decision — see §7.1) -**Recommendation: a 5th `LibraryCategory`** — Songs · Albums · Artists · Genres · **Playlists** — inside the Library tab. Mechanically a clean extension of the browse machinery (add `.playlists` to `LibraryCategory`; a `.playlist(Int64)` case to `LibraryRoute`; slots in `LibraryCategoryRoot`; `selectedCategory.didSet` clears `path` already). Nav state stays on `LibraryBrowseModel` (survives the tab `switch` teardown). -**Coherence caveat (the Fool):** playlists are user-*mutable*, the other four categories are read-only derived facets — one rail now mixes two mental models. Mitigations: playlists get their own affordances (create/rename/delete/drop), the built-in is hidden, and there is no manual playlist reordering. Rejected alternatives: a 5th top-level tab (overkill); a Finder-style sidebar rework (too big for R1). +### 0.1 Reviewer-corrected premises (carried from rev.2) +- **No scan-folder drop target exists** (folders added via open-panel; US-LIB-07 move-on-drag deferred) → reference-add safety is **type-level** (§2), not handler-discipline. +- **`FacetTrackListView`/`AlbumDetailView` are `List(selection:)`** — drop/reorder dead on macOS 26 → detail body reuses the **queue's `LazyVStack`/`PlaylistItemRow`**, header only. +- **`playlists()` returns the built-in first, unfiltered**, and its mutation verbs are unguarded for it → filter + guard everywhere. +- **`LibrarySidebar` is itself a `List(selection:)`** of static category labels → a Playlists *section* needing per-row drop + context-menu + inline rename can't live inside that `List` (drop won't fire, gestures race). The sidebar likely splits into the category `List` + a **`LazyVStack` Playlists tree** (SME to confirm the exact composition). + +### 0.2 DATA-LOSS RISK (must decide — §9.5) +The GRDB store uses `eraseDatabaseOnSchemaChange = true` because the song DB is a *rebuildable cache of on-disk files* ([[feedback-delete-rebuild-dev-db]]). **But playlists / folders / smart-rules are USER data that cannot be rebuilt.** Adding folders + smart-playlist tables bumps the schema → **wipes existing user playlists.** Fine in dev (drop-and-recreate always); **a data-loss bug for a shipped R1 user.** Options in §9.5. This must be resolved before the schema bump lands. + +## 1. IA — sidebar Playlists section (LOCKED: founder + research) +A **dedicated "Playlists" section in the left `LibrarySidebar`**, visually separate from the Songs/Albums/Artists/Genres category rail and the Music Folders accordion (the researched dominant pattern: Roon/MusicBee/Apple/Audirvana). **Scrollable / shows the full set** (Audirvana's ~2-row truncation is a documented "dealbreaker"). Selecting a playlist (or smart playlist) shows its detail in the content area (a `LibraryRoute.playlist(Int64)` / `.smartPlaylist(Int64)`). **Folders render as a nesting tree** (disclosure rows) within the section. Nav state on `LibraryBrowseModel`; data on `PlaylistsModel`. ## 2. Architecture -- **`PlaylistsModel` — a NEW `@MainActor @Observable` peer** (NOT an extension of `LibraryBrowseModel`, whose invariant is "read-only cache refreshed by scan-revision"). Owned in the composition root like `browse`, injected via `.environment`; composes `library` (store) + `audio` (queue verbs). Holds `list`, open `detail` (entries), and the mutating verbs. **Nav** (`selectedCategory == .playlists`, `LibraryRoute.playlist(id)`) stays on `LibraryBrowseModel`; playlist views read *nav* from browse + *data* from `PlaylistsModel`. - - **Refresh = authoritative re-read, epoch-guarded** (newest-wins token, mirroring `LibraryBrowseModel.loadAlbums`) after each mutation — a slow pre-mutation `playlists()` must not resurrect a deleted row. Small N → cost nil; `entryCount` is a SQL subquery + untitled/conflict aren't client-predictable, so re-read, not optimistic. - - **Also refresh on `library.libraryRevision`** — a scan reconcile can CASCADE-shrink a playlist (member track swept), changing counts/open detail with no playlist-side action. -- **Built-in "current" is INVISIBLE + INERT to playlists UI.** Filter `is_builtin == 0` in every surface (browse list, add-to-playlist targets, drop targets) via a pure `PlaylistBrowseVisibility.isUserVisible`. Belt-and-braces: reject `append`/`insert`/`reorder`/`replace` on `is_builtin` in the DAO too, so a UI leak degrades to an error, never silent queue-mirror corruption. -- **Reference-add safety is TYPE-LEVEL (US-PLIST-04).** The library-track drag payload is `LibraryTrackDragItem { trackID: Int64 }` (a *declared* UTType, §3). The add handler is `addTrackToPlaylist(trackID: Int64, playlistID:)` → `store.appendEntry` only — **you cannot move a file you know only by integer id**, a compile-time guarantee. Loose add (`addLooseFileToPlaylist(url:)`) reads tags + writes a row; it is read-only w.r.t. the file by construction (no `FileManager` move/copy anywhere in the playlist path — verified). Extract the drop routing into a pure `PlaylistDropRouter.route(payloads) -> [.referenceAdd(Int64)] | [.looseAdd(URL)]` (structurally cannot return a move) for testability. -- **Loose-file lifecycle (US-PLIST-02) — see §7.4.** A `folder_id=NULL` entry can point at a file that moves/deletes externally (not scan-monitored). Three distinct cases, to document + handle: (a) file deleted **and** its row swept by reconcile → entry CASCADE-drops (only for scan-folder tracks; loose rows are never swept); (b) loose file gone, row **not** swept → resolves at play → must **skip + flag**, not halt; (c) moved + re-matched (scan-folder) → survives (US-PLIST-08). Add an orphan sweep for unreferenced loose rows. - -## 3. UI components (reuse map — corrected) +- **`PlaylistsModel` — new `@MainActor @Observable` peer** (not extending the read-only, scan-revision browse cache). Composition-root-owned + injected; composes `library` (store) + `audio` (queue verbs). Holds the folder/playlist tree, open detail, smart-playlist results, and mutating verbs. **Epoch-guarded authoritative re-read** after each mutation (newest-wins); **also refresh on `library.libraryRevision`** (CASCADE shrink + smart-playlist re-evaluation depend on it). +- **Schema additions (drop-and-recreate; no migration):** + - **Folders:** a nullable `parent_folder_id` self-reference — simplest is a `playlist_folders(id, parent_id NULL REFERENCES playlist_folders(id) ON DELETE …, name, position, created_at)` table + `playlists.folder_id INTEGER NULL REFERENCES playlist_folders(id)`. Smart + static playlists both live in folders. Guard against parent-cycle on move. + - **Smart playlists:** `playlists.is_smart INTEGER NOT NULL DEFAULT 0` + a rules representation. Recommend a **normalized `playlist_rules` table** (playlist_id, field, op, value, conjunction) over a JSON blob (queryable, testable, no blob parsing) + `match_all/any`, optional `limit` + `sort`. Membership is **derived** (a built SQL `WHERE` over `tracks`), never stored as `playlist_entries`. +- **Reference-add safety = TYPE-LEVEL (US-PLIST-04):** library-track drag payload `LibraryTrackDragItem { trackID: Int64 }` (declared UTType); `addTrackToPlaylist(trackID:playlistID:)` → `store.appendEntry` only. Cannot move a file known only by id. Loose add reads tags + writes a row (read-only w.r.t. the file). No `FileManager` move/copy anywhere in the playlist path. Pure `PlaylistDropRouter` for testability. +- **Built-in "current" invisible + inert:** filter `is_builtin=0` in every surface (tree, add targets, drops) via pure `PlaylistBrowseVisibility`; DAO-reject mutations on the built-in. +- **Dead / loose files:** entry resolution yields `.ok | .missing`; **play SKIPS a `.missing` entry (never halts)**; detail shows a per-row "unavailable" indicator; actions: Locate (single) + "Remove missing" (bulk) + launch orphan-sweep of unreferenced loose rows. (Bulk relink/remap = future.) + +## 3. UI (reuse map — corrected + extended) | Piece | Reuse / build | |---|---| -| Playlists **list** (hundreds) | **`LazyVStack` of plain-Button rows** (AlbumGrid/Artists scaffolding + queue list), each row **also a `.dropDestination(for: LibraryTrackDragItem)`**; name + entry-count + inline rename + context menu. NOT `List(selection:)`. Fixed order (`is_builtin DESC, name`), so no grip/reorder. | -| Playlist **detail** body | **the queue's `PlaylistItemList`/`PlaylistItemRow`** (`LazyVStack` + grip `.draggable` + row `.dropDestination` + ↑/↓/Return/Delete) — the only reorder/drop-capable list here. Reuse **only the header** (title · "N songs · duration" · Play/Shuffle/More) from `FacetTrackListView`. `ForEach` keyed on **`PlaylistEntry.id`** (dupes allowed). | -| Two coexisting drops on detail | **two declared UTTypes** (`Info.plist UTExportedTypeDeclarations`): `…playlist-entry` (reorder) + `…library-track` (add). Two `.dropDestination(for:)`, each its own `isTargeted:` visual (insertion line vs container accent). Accept **only** internal types — reject `.fileURL`/`.audio` so a Finder drop never activates. (The String-`ProxyRepresentation` shortcut can't disambiguate two drops — both would register `public.utf8-plain-text`.) | -| Drag source | new `LibraryTrackDragItem(trackID: Int64)` on library rows (durable id, not the queue UUID). | -| "Add to Playlist" | **context menu**: `New Playlist…` + **top-N recent** playlists + `Add to Playlist…` → a **searchable sheet/popover** (`LazyVStack` filtered by `LibraryFilterField`). A flat all-playlists submenu doesn't scale + menus can't host search. Primary Songs path (operates on the multi-selection via `SongsRowResolver.orderedSelection` — no `Table` drag refactor). | -| Create / inline rename | `TextField` that **sets `KeyboardTransportFocus.isTextEntryFocused`** (else Space toggles playback); editing row tracked in **parent `@State` `editingID: Int64?`** (LazyVStack recycles rows); surface `PlaylistNameConflict` inline (red caption, stay in edit, Esc reverts); gate rename/delete off the built-in in the UI. | -| Play a playlist (§7.2) | resolve entries → `AudioFile` via the batched `tracksDisplay(ids:)` + `AudioFile(_:)` adapter (the **same seam queue hydration uses** — no N+1), then the existing queue-load verb. | -| Artwork in detail | share the **existing `ArtworkThumbnailStore`** (inject `browse.artworkImage` as a closure — the `NowPlayingController` precedent), not a second store. | -| Errors | existing `onError` → shell banner. | - -## 4. US-PLIST-08 — cross-seam test (real scanner, not a stub) -Extend the existing `checkMoveReferenceSurvives` scaffold (`ChecksMoveMatch.swift`) — real `ScanFixtureBuilder.writeFile` → real `LibraryScanner().scan` → capture id → real `FileManager.moveItem` → real re-scan. Swap its play_count/loved proxy for a **real playlist entry**: `createPlaylist` → `appendEntry(trackID:)` → move → re-scan → assert (a) entry `trackID` unchanged, (b) it now resolves to the **new** url/folder, (c) `entryCount` unchanged. Cover **both** same-root subdir move and cross-root move. - -## 5. QA plan (rev.2) -- **Pure (`swift test`, `LibraryBrowseKit`):** `PlaylistDropRouter` (playlist drop yields only add-ops, never a move — the real US-PLIST-04 proof); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → "Already in Playlist" toast vs add; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.fileNotFound` (retain) not `.drop`. -- **Strict-gate structural guard:** grep/semgrep that the playlist-drop handler file references no `moveItem`/`copyItem` (locks the "no shared move path" invariant a runtime test can't express). Drop the vacuous DAO "zero-fs-effect" check. -- **VerifyLibraryStore (headless, new):** `pl-move-membership-survives` (§4, real scanner); `pl-reorder-isolation` (same track in A & B, reorder A, B byte-identical + `tracks` rows untouched); `pl-explain-plan` (the `playlists()` correlated-COUNT + `entries()` use their indexes — plan **shape**, no ms budget); `pl-write-during-scan` (append/reorder concurrent with `scan`, incl. member swept mid-reorder → dense positions, no orphan/dup); built-in mutation rejected. -- **qa-expert + the-Fool break-it** post-impl (top attacks): boundary/wrong-target drop; loose→scanned identity then move; delete the open-detail playlist; rename race; reorder during sweep; duplicate-flood renumber+perf; hundreds of playlists (list + query plan); play a moved file (skip + retain, not halt); cross-playlist drag = **copy not move**; built-in never exposed. -- **Founder by-hand:** create/rename/delete; drag Songs→playlist (verify on disk **no file moved**); add a loose file; reorder; play a playlist; membership after a folder move; the drop affordances. - -## 6. Chunking (per-chunk build-gated loop) -- **A** — `PlaylistsModel` peer (epoch-guarded, revision-refresh) + `.playlists` category + browse **list** (LazyVStack rows, built-in filtered); seed rows to verify. `PlaylistBrowseVisibility` test. -- **B** — Playlist **detail** on the queue list body + header; play/remove/reorder; create/inline-rename/delete UX (focus-gate, inline conflict, built-in gated). `ForEach` on entry id. -- **C** — Add-to-playlist: context menu (New + recent) + searchable sheet (US-PLIST-02 incl. single loose file) + `LibraryTrackDragItem` declared UTType + drop-onto-playlist-row / detail add-drop (US-PLIST-03/04). **`PlaylistDropRouter` + `FileMover`-spy + strict-gate grep land HERE with the handler**, not later. -- **D** — US-PLIST-08 real-scanner check + `pl-reorder-isolation`/`pl-explain-plan`/`pl-write-during-scan` + loose-file skip/badge/GC (per §7.4) + strict-gate. - -## 7. Open founder-brainstorm decisions (recommendation first) -1. **IA placement** — 5th Library category *(recommended; mechanically clean, all reviewers)* vs top-level tab vs sidebar rework. Coherence caveat in §1. -2. **Play a playlist** — **replace the queue + a "Restore previous queue" undo toast** *(recommended)* vs append-to-queue vs replace-no-undo. (Replace silently vaporizes a curated queue *and* rewrites the queue-mirror — the Fool's FN-4; undo makes it safe.) -3. **Delete a playlist** — **undo toast** *(recommended — reversibility beats a speed-bump for a curation artifact)* vs confirm dialog. (Revised from rev.1's "confirm" per the Fool.) -4. **Loose-file degradation for R1** — **skip past an unreachable entry + a per-row "unavailable" badge + "Remove missing files" + launch orphan-sweep** *(recommended — the honest US-PLIST-02; a dead file must not brick a playthrough)* vs minimal (halt with a clear error, defer skip/GC). This is real added scope. - -**Estimate note:** several rev.1 "reuses" are actually net-new (the `LibraryTrackDragItem` + declared UTTypes/Info.plist, the searchable add-sheet, play-skip behavior, orphan GC). The 6 SP estimate is likely light; flag at planning. +| Sidebar Playlists **tree** | `LazyVStack` of disclosure rows (folders) + playlist rows; each playlist row is a `.dropDestination(for: LibraryTrackDragItem)`; scrolls; context menu (New Playlist / New Smart Playlist / New Folder / Rename / Delete); inline rename. NOT inside the category `List`. | +| Playlist **detail** body (static) | queue's `PlaylistItemList`/`PlaylistItemRow` (`LazyVStack` + grip `.draggable` reorder + Delete-to-remove). Header from `FacetTrackListView`. `ForEach` on `PlaylistEntry.id`. | +| Smart-playlist **detail** | same list body but **read-only** (no manual reorder/remove/add — membership is rule-derived); header shows the rule summary + "Edit Rules". | +| Two coexisting drops (detail) | two **declared UTTypes** (`playlist-entry` reorder + `library-track` add); reject `.fileURL`/`.audio`. | +| Add-to-playlist | context menu: `New Playlist…` + top-N recent + `Add to Playlist…` → **searchable sheet** (`LibraryFilterField`); primary Songs path (operates on `SongsRowResolver.orderedSelection` — no `Table` drag refactor). | +| Smart-rule **builder** | a sheet: rows of (field ▸ op ▸ value) + match-all/any + limit + sort; fields from the tracks schema (title/artist/album/genre/year/rating/loved/play_count/last_played/date_added/duration/format/sample_rate/bit_depth/frecency). | +| Create / rename | `TextField` that sets `KeyboardTransportFocus.isTextEntryFocused`; editing id in parent `@State`; inline `PlaylistNameConflict`; built-in gated. | +| Play a playlist | resolve entries → `AudioFile` via batched `tracksDisplay(ids:)` + `AudioFile(_:)` (queue-hydration seam; no N+1). | +| Artwork | share the existing `ArtworkThumbnailStore` (inject `browse.artworkImage` closure). | + +## 4. Play semantics (LOCKED: verb set + undo) +Three verbs on a playlist/folder/selection: **Play** (replace the active queue), **Play Next** (insert after current), **Add to Queue** (append) — mirrors Roon/Apple. **Play (replace) is REVERSIBLE**: snapshot the pre-replace queue and show a **"Restore previous queue" undo toast** (a differentiation opening — no comparable offers it; the top real-world queue frustration). Single-track play does **not** over-enqueue the rest. The built-in current-queue stays the invisible playback surface. + +## 5. Folders (LOCKED) +Nested folders holding static + smart playlists; sidebar disclosure tree; drag a playlist into a folder (reference move within the tree, never a file op); create/rename/delete (deleting a folder → decide: delete contents vs orphan-to-root — §9.2). Names need not be unique across folders (path-scoped) — decide vs global. Cycle-guard on reparent. + +## 6. Smart playlists (LOCKED) +Rule-based, auto-updating, **read-only membership** (derived by evaluating rules against `tracks`). Auto-refresh on open + on `libraryRevision` (§9.3 cadence). Playable via the same verbs (resolve → queue). Not drop targets (can't manually add). Rule builder in §3. v1 criteria set = §9.1 (founder to confirm). Evaluation = a pure rule→SQL `WHERE` builder (testable) + a bounded query (indexed where possible; EXPLAIN-plan tripwire). + +## 7. QA plan (rev.3) +- **Pure (`swift test`, LibraryBrowseKit):** `PlaylistDropRouter` (drop → add-ops only, never move); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → toast; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.missing` (retain, skip); **`SmartRuleSQLBuilder`** (each field/op → correct WHERE + args; match-all/any; injection-safe binding; limit/sort); **folder-tree** helpers (cycle-guard on reparent; delete semantics). +- **Strict-gate grep:** the playlist-drop handler references no `moveItem`/`copyItem`. +- **VerifyLibraryStore (headless, new):** `pl-move-membership-survives` (real scanner, §rev.2); `pl-reorder-isolation`; `pl-explain-plan` (static list + entries + **smart-eval** query use indexes); `pl-write-during-scan`; built-in mutation rejected; **`pl-folder-*`** (nest/reparent/cycle-reject/delete-cascade-or-orphan); **`pl-smart-eval`** (seed tracks → rules → assert derived membership; re-eval after a scan changes membership); loose orphan-sweep. +- **qa-expert + Fool break-it** post-impl: wrong-target drop; delete open-detail playlist/folder; rename race; reorder during sweep; duplicate flood; hundreds of playlists + deep nesting (tree perf + query plan); play a moved file (skip+retain); cross-playlist drag = copy; built-in never exposed; **smart rule that matches 50k tracks** (bound/paginate); **circular folder reparent**; **schema-bump data-loss** (§0.2). +- **Founder by-hand:** create/rename/delete (playlist, smart, folder); drag Songs→playlist (verify no file moved); nest folders; build a smart rule + watch it update after a scan; the three play verbs + undo; dead-file skip + remove-missing. + +## 8. Chunking (phased, build-gated) +- **A** — schema (folders + smart tables/columns) + DAO (folder CRUD/reparent/cycle-guard; smart CRUD; `SmartRuleSQLBuilder`) + `PlaylistsModel` peer + §0.2 data-loss resolution. Headless checks. +- **B** — sidebar **Playlists section** (flat first): list user playlists (built-in filtered), scroll, select→detail, create/inline-rename/delete. +- **C** — static playlist **detail** (queue list body): play (verbs), remove, reorder; `ForEach` on entry id. +- **D** — **folders**: tree/disclosure in the sidebar, create/rename/delete, drag-playlist-into-folder, cycle-guard. +- **E** — add-to-playlist: context menu + searchable sheet (US-PLIST-02 incl. loose file) + `LibraryTrackDragItem` UTType + drops (US-PLIST-03/04); `PlaylistDropRouter` + FileMover-spy + strict-gate grep **land here**. +- **F** — **smart playlists**: rule builder sheet + read-only detail + auto-refresh + `pl-smart-eval`. +- **G** — dead/loose: skip-on-play + unavailable indicator + Locate + remove-missing + orphan-sweep. +- **H** — US-PLIST-08 real-scanner seam + `pl-reorder-isolation`/`pl-explain-plan`/`pl-write-during-scan` + strict-gate. + +## 9. Open founder-brainstorm decisions (recommendation first) +1. **Smart-playlist v1 criteria set** — *recommended:* artist, album, genre, year, rating, loved, play_count, last_played, date_added, duration, format, sample_rate/bit_depth (audiophile-relevant), frecency; ops =/≠/contains/>/ Date: Wed, 15 Jul 2026 14:34:43 +0530 Subject: [PATCH 05/15] =?UTF-8?q?docs(s10.3):=20design=20rev.4=20=E2=80=94?= =?UTF-8?q?=20LOCKED=20(3=20review=20rounds=20+=20founder=20decisions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-loss myth corrected (appending migrations is safe; verified vs GRDB source + repo v4). Founder decisions locked: - Smart playlists DECOUPLED to post-R1 (design preserved in a Deferred section) → R1 = static + folders + sidebar IA + Play/Next/Queue+undo + dead-file (~10-12 SP). - Separate user-data.sqlite3 store (additive-only, never erased); library cache stays nuke-and-rebuild. Cross-store soft-refs; the track-delete CASCADE becomes an app-layer orphan sweep on libraryRevision (= founder's 'track deleted → remove from playlist' rule; moved+re-matched track survives). - Global-unique playlist names. - Delete a playlist folder = cascade-delete contents, guarded by undo (subtree snapshot). - Sidebar = one ScrollView/LazyVStack + unified selection enum; flattened folder tree (no OutlineGroup). Detail = new PlaylistDetailList reusing PlaylistItemRow. Undo snapshots the in-memory queue synchronously. Phased chunks A0-G; A0 (store split + relocate S10.2 tables) is the load-bearing refactor, gated by a ud-survives-schema-bump green check. --- docs/sprints/s10-3-playlists-ux-design.md | 120 +++++++++++----------- 1 file changed, 59 insertions(+), 61 deletions(-) diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md index 03a49f9..1b70459 100644 --- a/docs/sprints/s10-3-playlists-ux-design.md +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -1,75 +1,73 @@ -# S10.3 — Playlists UX — design (rev. 3 — research-grounded, scope expanded) +# S10.3 — Playlists UX — design (rev. 4 — LOCKED, buildable) -Last R1 gate. Founder expanded scope (2026-07-15, post market research — see [research memo](s10-3-playlists-ux-research.md)) to match category norms: **sidebar Playlists section + folders/nesting + smart (rules-based) playlists + Play/Next/Queue verb set with undo.** Stories: US-PLIST-02, -03, -04, -08 **+ new: playlist folders, smart playlists.** M3U import-export still deprioritized. +Last R1 gate. Research-grounded ([memo](s10-3-playlists-ux-research.md)) + three SME/Fool review rounds + founder decisions (2026-07-15). **Smart playlists DECOUPLED to post-R1** (see §Deferred). R1 scope: **static playlists + playlist folders + sidebar IA + Play/Next/Queue verbs with undo + dead-file handling.** Stories US-PLIST-02/-03/-04/-08 + folders. -> **Re-estimate:** rev.1/2 was ~6 SP (UI over the existing static DAO). Folders + smart playlists are **net-new schema + DAO + UI** — realistically **~15–18 SP**. Recommend delivering as phased chunks (A–H, §8), each independently gated; R1 can gate on the static+folders+IA core (A–E) with smart playlists (F–G) as a fast follow if timeline pressure appears. Flagging, not relitigating — the scope calls are locked. +## Locked decisions (founder) +- **D-IA:** playlists live in a **dedicated sidebar section** (not a 5th browse category). Research norm + resolves the mutable-vs-read-only coherence concern. +- **D-store:** **separate `user-data.sqlite3` store** for playlists/folders/entries (additive-only, `eraseDatabaseOnSchemaChange = false`); `library.sqlite3` stays the nuke-and-rebuild cache. §2. +- **D-names:** playlist names are **globally unique** (keep `UNIQUE(name) WHERE is_builtin=0`). +- **D-folder-delete:** deleting a playlist folder **deletes its contents** (playlists + subfolders), guarded by an **undo** (snapshot the subtree for restore). Strict "folder owns its contents". +- **D-play:** **Play (replace queue) / Play Next / Add to Queue**; Play-replace is **reversible** via a "Restore previous queue" undo toast. +- **D-smart:** smart/auto playlists are a **post-R1 fast-follow** (appending their migration later is free + lossless — §Deferred preserves the vetted design). -## 0. Store status -S10.1 built the **static-playlist DAO** (create/rename/delete/append/insert/remove/reorder/loose-add, built-in "current", name-conflict, CASCADE; 22 headless checks). **NET-NEW for rev.3:** (a) **playlist folders** (hierarchy) and (b) **smart playlists** (rules + evaluation) — both need schema + DAO. Confirmed absent today. +## 0. Corrected premise (was rev.3 §0.2 — WRONG) +Rev.3 claimed "adding folders/smart tables wipes playlists." **False** (architect + Fool, verified against GRDB source + the repo's own v4). `eraseDatabaseOnSchemaChange` erases only on a **downgrade** or an **edited already-shipped migration body** — never on **appending** a new migration. v4 (frecency) appended after v3 (playlists) and preserved data. The real risk is *whole-file collateral erase* from a future edited-migration or a corruption/quarantine rebuild — which **D-store** structurally removes by putting user data in its own never-erased file. -### 0.1 Reviewer-corrected premises (carried from rev.2) -- **No scan-folder drop target exists** (folders added via open-panel; US-LIB-07 move-on-drag deferred) → reference-add safety is **type-level** (§2), not handler-discipline. -- **`FacetTrackListView`/`AlbumDetailView` are `List(selection:)`** — drop/reorder dead on macOS 26 → detail body reuses the **queue's `LazyVStack`/`PlaylistItemRow`**, header only. -- **`playlists()` returns the built-in first, unfiltered**, and its mutation verbs are unguarded for it → filter + guard everywhere. -- **`LibrarySidebar` is itself a `List(selection:)`** of static category labels → a Playlists *section* needing per-row drop + context-menu + inline rename can't live inside that `List` (drop won't fire, gestures race). The sidebar likely splits into the category `List` + a **`LazyVStack` Playlists tree** (SME to confirm the exact composition). +## 1. IA — sidebar Playlists section (D-IA) +A dedicated "Playlists" section in the left sidebar, **separate** from the Songs/Albums/Artists/Genres category rail and the Music Folders accordion. **Scrollable, shows the full set** (Audirvana's 2-row truncation is a documented "dealbreaker"). Folders render as a **flattened disclosure tree**. Selecting a playlist → detail in the content area. +- **Composition (swiftui-pro):** collapse the whole sidebar to **one `ScrollView { LazyVStack }` of plain `Button` rows** with a single selection enum — drop `List(selection:)` (its drops don't fire + it races gestures + double-highlight with a second selection system). Keep the `.safeAreaInset(edge:.bottom)` Music Folders footer. + ```swift + enum SidebarSelection: Hashable { case category(LibraryCategory), playlist(Int64) } + ``` + Re-implement ↑/↓ + ←/→ (collapse/expand) via `.onKeyPress` + `@FocusState` (as `PlaylistItemList` does); re-create the selection capsule with `DesignSystem.Color.rowSelected`. +- **Folder tree (swiftui-pro):** **flatten the expanded tree into a depth-annotated array** (`SidebarNode { id, kind, depth }`) and render flat — do NOT use `OutlineGroup`/`DisclosureGroup` (they re-introduce `List` dead-drops OR break LazyVStack laziness + nest drop hit-regions). Expansion = `Set` **on the model** (tab switch destroys the view), persisted (`@AppStorage`-JSON, matching `library.foldersExpanded.v1`). Soft-cap indent (~5 levels). -### 0.2 DATA-LOSS RISK (must decide — §9.5) -The GRDB store uses `eraseDatabaseOnSchemaChange = true` because the song DB is a *rebuildable cache of on-disk files* ([[feedback-delete-rebuild-dev-db]]). **But playlists / folders / smart-rules are USER data that cannot be rebuilt.** Adding folders + smart-playlist tables bumps the schema → **wipes existing user playlists.** Fine in dev (drop-and-recreate always); **a data-loss bug for a shipped R1 user.** Options in §9.5. This must be resolved before the schema bump lands. +## 2. Architecture — the separate user-data store (D-store, load-bearing) +- **`UserDataStore`** — a second GRDB store (`user-data.sqlite3`) with its own `DatabaseMigrator`, **`eraseDatabaseOnSchemaChange = false`**, **additive-only frozen-shipped-body discipline**. Holds `playlists`, `playlist_entries`, `playlist_folders` (+ post-R1 `playlist_rules`), and the built-in "current" queue. `library.sqlite3` is unchanged (cache, erase=true). +- **Migrate the S10.1/S10.2 playlist tables out of `library.sqlite3` into `UserDataStore`** (chunk **A0**). Repoint the two shipped S10.2 consumers: `AudioViewModel+QueueMirror.swift` (`replaceEntries`/`currentPlaylistID`) + `AudioViewModel+QueueHydration.swift`. The `LibraryStore+Playlists.swift` DAO moves to the new store. +- **No cross-file FK** (SQLite can't). `playlist_entries.track_id` is a **soft reference** (durable `Int64`). The old `ON DELETE CASCADE` (track delete → drop entry) becomes an **app-layer sweep**: on `library.libraryRevision` change, delete `playlist_entries` whose `track_id` no longer exists in `library.tracks` (this is exactly the founder's "track deleted/moved → remove from playlist" rule; a moved+re-matched track keeps its id, so it survives — US-PLIST-08). Track *identity* stays authoritative in `library.sqlite3`; loose-add writes the track row there, the entry soft-refs the returned id. +- **`PlaylistsModel`** — new `@MainActor @Observable` composition-root peer over `UserDataStore` (+ `library` for track identity/display, `audio` for queue verbs). Owns the folder/playlist tree, open detail, verbs. **Per-surface epoch tokens** (tree / detail-entries) like `LibraryBrowseModel`; refresh on mutation + on `libraryRevision` (for the orphan sweep). Nav (`SidebarSelection`, `LibraryRoute.playlist(Int64)`) on `LibraryBrowseModel`. +- **Reference-add = TYPE-LEVEL (US-PLIST-04):** `LibraryTrackDragItem { trackID: Int64 }` (declared UTType); `addTrackToPlaylist(trackID:playlistID:)` → `appendEntry` only. Can't move a file known by id. No `FileManager` move/copy in the playlist path (strict-gate grep backstop). Pure `PlaylistDropRouter`. +- **Built-in "current" invisible + inert:** filter `is_builtin=0` everywhere (pure `PlaylistBrowseVisibility`); DAO-reject mutations on it. +- **Dead / loose files:** entry resolution → `.ok | .missing`; **play SKIPS `.missing` (never halts)**; per-row unavailable indicator; Locate + bulk "Remove missing" + launch orphan-sweep. -## 1. IA — sidebar Playlists section (LOCKED: founder + research) -A **dedicated "Playlists" section in the left `LibrarySidebar`**, visually separate from the Songs/Albums/Artists/Genres category rail and the Music Folders accordion (the researched dominant pattern: Roon/MusicBee/Apple/Audirvana). **Scrollable / shows the full set** (Audirvana's ~2-row truncation is a documented "dealbreaker"). Selecting a playlist (or smart playlist) shows its detail in the content area (a `LibraryRoute.playlist(Int64)` / `.smartPlaylist(Int64)`). **Folders render as a nesting tree** (disclosure rows) within the section. Nav state on `LibraryBrowseModel`; data on `PlaylistsModel`. +## 3. Folders (D-folder-delete) +Adjacency-list `playlist_folders(id, parent_id NULL self-ref, name, position, created_at)` + `playlists.folder_id`. Nested tree; drag a playlist/folder into a folder (reparent — a `SidebarNodeMoveItem` UTType, never a file op). **Cycle-guard inside the write txn** via `WITH RECURSIVE` ancestor walk (reject target == node OR target ∈ descendants(node)). **Delete = CASCADE the contents** (`ON DELETE CASCADE` on `parent_id` + `folder_id`) **+ undo:** snapshot the deleted subtree (folders + playlists + entries) before delete; the undo toast restores it. Depth soft-cap ~5. -## 2. Architecture -- **`PlaylistsModel` — new `@MainActor @Observable` peer** (not extending the read-only, scan-revision browse cache). Composition-root-owned + injected; composes `library` (store) + `audio` (queue verbs). Holds the folder/playlist tree, open detail, smart-playlist results, and mutating verbs. **Epoch-guarded authoritative re-read** after each mutation (newest-wins); **also refresh on `library.libraryRevision`** (CASCADE shrink + smart-playlist re-evaluation depend on it). -- **Schema additions (drop-and-recreate; no migration):** - - **Folders:** a nullable `parent_folder_id` self-reference — simplest is a `playlist_folders(id, parent_id NULL REFERENCES playlist_folders(id) ON DELETE …, name, position, created_at)` table + `playlists.folder_id INTEGER NULL REFERENCES playlist_folders(id)`. Smart + static playlists both live in folders. Guard against parent-cycle on move. - - **Smart playlists:** `playlists.is_smart INTEGER NOT NULL DEFAULT 0` + a rules representation. Recommend a **normalized `playlist_rules` table** (playlist_id, field, op, value, conjunction) over a JSON blob (queryable, testable, no blob parsing) + `match_all/any`, optional `limit` + `sort`. Membership is **derived** (a built SQL `WHERE` over `tracks`), never stored as `playlist_entries`. -- **Reference-add safety = TYPE-LEVEL (US-PLIST-04):** library-track drag payload `LibraryTrackDragItem { trackID: Int64 }` (declared UTType); `addTrackToPlaylist(trackID:playlistID:)` → `store.appendEntry` only. Cannot move a file known only by id. Loose add reads tags + writes a row (read-only w.r.t. the file). No `FileManager` move/copy anywhere in the playlist path. Pure `PlaylistDropRouter` for testability. -- **Built-in "current" invisible + inert:** filter `is_builtin=0` in every surface (tree, add targets, drops) via pure `PlaylistBrowseVisibility`; DAO-reject mutations on the built-in. -- **Dead / loose files:** entry resolution yields `.ok | .missing`; **play SKIPS a `.missing` entry (never halts)**; detail shows a per-row "unavailable" indicator; actions: Locate (single) + "Remove missing" (bulk) + launch orphan-sweep of unreferenced loose rows. (Bulk relink/remap = future.) - -## 3. UI (reuse map — corrected + extended) +## 4. UI (reuse map — corrected) | Piece | Reuse / build | |---|---| -| Sidebar Playlists **tree** | `LazyVStack` of disclosure rows (folders) + playlist rows; each playlist row is a `.dropDestination(for: LibraryTrackDragItem)`; scrolls; context menu (New Playlist / New Smart Playlist / New Folder / Rename / Delete); inline rename. NOT inside the category `List`. | -| Playlist **detail** body (static) | queue's `PlaylistItemList`/`PlaylistItemRow` (`LazyVStack` + grip `.draggable` reorder + Delete-to-remove). Header from `FacetTrackListView`. `ForEach` on `PlaylistEntry.id`. | -| Smart-playlist **detail** | same list body but **read-only** (no manual reorder/remove/add — membership is rule-derived); header shows the rule summary + "Edit Rules". | -| Two coexisting drops (detail) | two **declared UTTypes** (`playlist-entry` reorder + `library-track` add); reject `.fileURL`/`.audio`. | -| Add-to-playlist | context menu: `New Playlist…` + top-N recent + `Add to Playlist…` → **searchable sheet** (`LibraryFilterField`); primary Songs path (operates on `SongsRowResolver.orderedSelection` — no `Table` drag refactor). | -| Smart-rule **builder** | a sheet: rows of (field ▸ op ▸ value) + match-all/any + limit + sort; fields from the tracks schema (title/artist/album/genre/year/rating/loved/play_count/last_played/date_added/duration/format/sample_rate/bit_depth/frecency). | -| Create / rename | `TextField` that sets `KeyboardTransportFocus.isTextEntryFocused`; editing id in parent `@State`; inline `PlaylistNameConflict`; built-in gated. | -| Play a playlist | resolve entries → `AudioFile` via batched `tracksDisplay(ids:)` + `AudioFile(_:)` (queue-hydration seam; no N+1). | -| Artwork | share the existing `ArtworkThumbnailStore` (inject `browse.artworkImage` closure). | - -## 4. Play semantics (LOCKED: verb set + undo) -Three verbs on a playlist/folder/selection: **Play** (replace the active queue), **Play Next** (insert after current), **Add to Queue** (append) — mirrors Roon/Apple. **Play (replace) is REVERSIBLE**: snapshot the pre-replace queue and show a **"Restore previous queue" undo toast** (a differentiation opening — no comparable offers it; the top real-world queue frustration). Single-track play does **not** over-enqueue the rest. The built-in current-queue stays the invisible playback surface. +| Sidebar tree | `ScrollView { LazyVStack }` of Button rows (§1); each playlist row a `.dropDestination(for: LibraryTrackDragItem)`; grip `.draggable(SidebarNodeMoveItem)` for reparent (grip, not row-wide — avoids the FB7367473 tap/drag race). | +| Playlist **detail** | NEW `PlaylistDetailList` reusing **`PlaylistItemRow`** (NOT `PlaylistItemList` — it's `private` + hardwired to `AudioViewModel.queue`). Same ScrollView+LazyVStack+`@FocusState`+`.onKeyPress` scaffolding. `ForEach` on `PlaylistEntry.id` (dupes allowed). Reorder via grip `.draggable(PlaylistEntryDragItem)` + row `.dropDestination`. | +| Two drops on detail | two declared UTTypes (`playlist-entry` reorder + `library-track` add); reject `.fileURL`/`.audio`. | +| Add-to-playlist | context menu (`New Playlist…` + top-N recent + `Add to Playlist…` → **searchable sheet** reusing `LibraryFilterField`); primary Songs path via `SongsRowResolver.orderedSelection` (no Table drag refactor). | +| Create / rename | `TextField` with a reusable **`.transportFocusGate($focused)`** (extract from `LibraryFilterField` — else Space toggles playback); editing id in parent `@State`; inline `PlaylistNameConflict`; built-in gated. | +| Play | resolve entries → `AudioFile` via batched `tracksDisplay(ids:)` + `AudioFile(_:)` (queue-hydration seam, no N+1). | +| Play undo | snapshot the **in-memory `queue` array synchronously** at replace time (NOT the debounced mirror — stale + drops loose slots); one transient "Restore previous queue" toast (one-level). | +| Artwork | share the existing `ArtworkThumbnailStore` (inject `browse.artworkImage`). | -## 5. Folders (LOCKED) -Nested folders holding static + smart playlists; sidebar disclosure tree; drag a playlist into a folder (reference move within the tree, never a file op); create/rename/delete (deleting a folder → decide: delete contents vs orphan-to-root — §9.2). Names need not be unique across folders (path-scoped) — decide vs global. Cycle-guard on reparent. +## 5. QA plan (R1) +- **Pure (`swift test`, LibraryBrowseKit):** `PlaylistDropRouter` (drop → add-ops only, never move); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → toast; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.missing` (retain, skip); folder cycle-guard (self + descendant). +- **Strict-gate grep:** playlist-drop handler references no `moveItem`/`copyItem`. +- **VerifyUserDataStore / VerifyLibraryStore (headless, new):** **`ud-survives-schema-bump`** (GREEN-GATE, load-bearing: seed playlists+folders → register a throwaway additive migration → assert every user row survives — proves D-store); `pl-move-membership-survives` (real scanner, US-PLIST-08); **`pl-orphan-sweep`** (delete a track in library → the cross-store sweep drops its entries; moved track keeps membership); `pl-reorder-isolation`; `pl-folder-cascade-delete` + cycle-reject; `pl-explain-plan` (list + entries use indexes); `pl-write-during-scan`; built-in mutation rejected. +- **qa-expert + Fool break-it** post-impl: wrong-target drop; delete open-detail playlist/folder; rename race; reorder during sweep; duplicate flood; hundreds of playlists + deep nesting; play a moved file (skip+retain); cross-playlist drag = copy; built-in never exposed; cross-store crash between loose-add's two writes (→ harmless orphan/.missing). +- **Founder by-hand:** create/rename/delete (playlist, folder + undo); drag Songs→playlist (verify no file moved on disk); nest folders; the three play verbs + restore-queue undo; dead-file skip + remove-missing. -## 6. Smart playlists (LOCKED) -Rule-based, auto-updating, **read-only membership** (derived by evaluating rules against `tracks`). Auto-refresh on open + on `libraryRevision` (§9.3 cadence). Playable via the same verbs (resolve → queue). Not drop targets (can't manually add). Rule builder in §3. v1 criteria set = §9.1 (founder to confirm). Evaluation = a pure rule→SQL `WHERE` builder (testable) + a bounded query (indexed where possible; EXPLAIN-plan tripwire). +## 6. Chunking (phased, build-gated) +- **A0** — `UserDataStore` (separate additive-only store) + migrate playlist tables out of `library.sqlite3` + repoint S10.2 consumers + `ud-survives-schema-bump` green-gate. *(The load-bearing refactor — do first, gate hard.)* +- **A** — `playlist_folders` schema + folder DAO (CRUD/reparent/cycle-guard/cascade-delete-with-subtree-snapshot) + `PlaylistsModel` peer + cross-store orphan sweep on `libraryRevision` (`pl-orphan-sweep`). +- **B** — sidebar Playlists section (flat first): one ScrollView/LazyVStack, unified selection enum, built-in filtered, scroll, select→detail, create/inline-rename/delete. +- **C** — `PlaylistDetailList` (queue-row reuse): play (verbs + undo), remove, reorder; `ForEach` on entry id. +- **D** — folders UI: flattened disclosure tree, create/rename/delete(+undo), drag-reparent, cycle-guard, depth cap. +- **E** — add-to-playlist: context menu + searchable sheet (US-PLIST-02 incl. loose file) + `LibraryTrackDragItem` UTType + drops (US-PLIST-03/04); `PlaylistDropRouter` + FileMover-spy + strict-gate grep **here**. +- **F** — dead/loose: skip-on-play + unavailable indicator + Locate + remove-missing + orphan-sweep UI. +- **G** — US-PLIST-08 real-scanner seam + `pl-reorder-isolation`/`pl-explain-plan`/`pl-write-during-scan` + strict-gate. -## 7. QA plan (rev.3) -- **Pure (`swift test`, LibraryBrowseKit):** `PlaylistDropRouter` (drop → add-ops only, never move); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → toast; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.missing` (retain, skip); **`SmartRuleSQLBuilder`** (each field/op → correct WHERE + args; match-all/any; injection-safe binding; limit/sort); **folder-tree** helpers (cycle-guard on reparent; delete semantics). -- **Strict-gate grep:** the playlist-drop handler references no `moveItem`/`copyItem`. -- **VerifyLibraryStore (headless, new):** `pl-move-membership-survives` (real scanner, §rev.2); `pl-reorder-isolation`; `pl-explain-plan` (static list + entries + **smart-eval** query use indexes); `pl-write-during-scan`; built-in mutation rejected; **`pl-folder-*`** (nest/reparent/cycle-reject/delete-cascade-or-orphan); **`pl-smart-eval`** (seed tracks → rules → assert derived membership; re-eval after a scan changes membership); loose orphan-sweep. -- **qa-expert + Fool break-it** post-impl: wrong-target drop; delete open-detail playlist/folder; rename race; reorder during sweep; duplicate flood; hundreds of playlists + deep nesting (tree perf + query plan); play a moved file (skip+retain); cross-playlist drag = copy; built-in never exposed; **smart rule that matches 50k tracks** (bound/paginate); **circular folder reparent**; **schema-bump data-loss** (§0.2). -- **Founder by-hand:** create/rename/delete (playlist, smart, folder); drag Songs→playlist (verify no file moved); nest folders; build a smart rule + watch it update after a scan; the three play verbs + undo; dead-file skip + remove-missing. +## 7. Open sub-decisions (recommend + proceed unless vetoed) +- **Play a folder** — *recommended:* plays all descendant playlists' tracks in tree order (bounded by a queue-size safety cap). Same for a multi-playlist selection. +- **Backup/export of user data** — *recommended:* out of R1 scope, but note as the belt-and-braces follow-up (covers a corrupt `user-data.sqlite3` — the one thing the store-split alone can't). Not blocking. -## 8. Chunking (phased, build-gated) -- **A** — schema (folders + smart tables/columns) + DAO (folder CRUD/reparent/cycle-guard; smart CRUD; `SmartRuleSQLBuilder`) + `PlaylistsModel` peer + §0.2 data-loss resolution. Headless checks. -- **B** — sidebar **Playlists section** (flat first): list user playlists (built-in filtered), scroll, select→detail, create/inline-rename/delete. -- **C** — static playlist **detail** (queue list body): play (verbs), remove, reorder; `ForEach` on entry id. -- **D** — **folders**: tree/disclosure in the sidebar, create/rename/delete, drag-playlist-into-folder, cycle-guard. -- **E** — add-to-playlist: context menu + searchable sheet (US-PLIST-02 incl. loose file) + `LibraryTrackDragItem` UTType + drops (US-PLIST-03/04); `PlaylistDropRouter` + FileMover-spy + strict-gate grep **land here**. -- **F** — **smart playlists**: rule builder sheet + read-only detail + auto-refresh + `pl-smart-eval`. -- **G** — dead/loose: skip-on-play + unavailable indicator + Locate + remove-missing + orphan-sweep. -- **H** — US-PLIST-08 real-scanner seam + `pl-reorder-isolation`/`pl-explain-plan`/`pl-write-during-scan` + strict-gate. +--- -## 9. Open founder-brainstorm decisions (recommendation first) -1. **Smart-playlist v1 criteria set** — *recommended:* artist, album, genre, year, rating, loved, play_count, last_played, date_added, duration, format, sample_rate/bit_depth (audiophile-relevant), frecency; ops =/≠/contains/>// Date: Wed, 15 Jul 2026 15:51:15 +0530 Subject: [PATCH 06/15] refactor(s10.3): one store, stop erasing user data (reverse the A0 split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QA break-it (qa-expert + Fool) overturned the separate-user-data-store decision: its cross-store soft ref was tracks.id (a bare rowid, no AUTOINCREMENT) that a library rebuild reassigns → playlists silently resolved to the WRONG track (worse than a clean wipe); and it protected only playlists while play_count/loved/rating/ frecency stayed in the erased cache. Root cause: library.sqlite3 was never a pure cache — it holds user data too. Founder decision: ONE store, stop erasing user data. - LibraryStore.makeMigrator: eraseDatabaseOnSchemaChange = false; migrations are additive-only, frozen-body. Playlists stay in library.sqlite3 with the same-file FK ON DELETE CASCADE (atomic deleted-track cleanup, no app-layer sweep), id stability, and US-PLIST-08 (move-match keeps id). The derived cache is rebuilt by re-scan, not by wiping the file. - Replaced checkEraseOnSchemaChange with additive-preserve-schema-bump (proves an appended migration PRESERVES seeded user data) — the meaningful invariant now. - Reverted the separate UserDataStore + DAO relocation + consumer repoints (the A0 detour, discarded). - Updated comments (LibraryStore/Schema), memory (feedback-delete-rebuild-dev-db scoped/reversed), and the design doc to rev.5 with the full store-decision history. make strict-gate green. --- Sources/LibraryStore/LibraryStore.swift | 34 +++++--- Sources/LibraryStore/Schema.swift | 4 +- .../VerifyLibraryStore/ChecksCorruption.swift | 77 +++++++++---------- Sources/VerifyLibraryStore/main.swift | 4 +- docs/sprints/s10-3-playlists-ux-design.md | 36 +++++---- 5 files changed, 83 insertions(+), 72 deletions(-) diff --git a/Sources/LibraryStore/LibraryStore.swift b/Sources/LibraryStore/LibraryStore.swift index 028b86f..8635193 100644 --- a/Sources/LibraryStore/LibraryStore.swift +++ b/Sources/LibraryStore/LibraryStore.swift @@ -16,9 +16,13 @@ // is no long-lived connection handle to interleave, and a closure body is synchronous. // Only `Sendable` value types cross the boundary; no `Database`/handle ever escapes. // -// The library store is a REBUILDABLE CACHE of on-disk files (design §2a): corruption is -// quarantined + rebuilt, and a schema change simply recreates the database -// (`eraseDatabaseOnSchemaChange`) — there are no users whose state must be preserved. +// The library store holds TWO kinds of data: a rebuildable CACHE of on-disk files (scan-built +// track/album/artist/genre/FTS rows) AND non-rebuildable USER data (playlists/entries + the track +// user-state columns play_count/loved/rating/last_played/frecency_*). Because of the latter, +// `eraseDatabaseOnSchemaChange` is FALSE (S10.3) — a schema change is an ADDITIVE, frozen-body +// migration that PRESERVES user data; the cache is rebuilt by a re-scan, never by wiping the file. +// Corruption is still quarantined + rebuilt (a genuinely-unreadable file) — the deferred backup/ +// export is the durability answer for user data on that last-resort path. import Foundation import GRDB @@ -82,8 +86,8 @@ public final class LibraryStore: Sendable { /// Open (creating if absent) and migrate the store at `url`. Corruption / a failed /// integrity check quarantine the file (+ its `-wal`/`-shm` sidecars) and rebuild - /// fresh; a schema change recreates the database. Never crashes, never silently - /// deletes (design §5). + /// fresh; a schema change is an ADDITIVE migration that PRESERVES data (erase=false, + /// S10.3). Never crashes, never silently deletes (design §5). /// /// - Parameters: /// - url: the store file URL (`:memory:` for an in-memory database). @@ -280,8 +284,8 @@ public final class LibraryStore: Sendable { /// Open + migrate the store at `url`. On a rebuild-recoverable failure for a /// PRE-EXISTING file — it cannot be opened/queried as a database, or `integrity_check` /// fails — the file (+ its `-wal`/`-shm` sidecars) is quarantined and a fresh store is - /// rebuilt. Never crashes, never silently deletes (design §5). A schema change is - /// handled inside GRDB by `eraseDatabaseOnSchemaChange` (rebuildable cache, no users). + /// rebuilt. Never crashes, never silently deletes (design §5). A schema change is an + /// ADDITIVE, frozen-body migration that PRESERVES user data (erase=false, S10.3). private static func openMigratingAndRepairing( url: URL, appBuild: String?, stamp: String ) throws -> (any DatabaseWriter, Int) { @@ -352,12 +356,20 @@ public final class LibraryStore: Sendable { } /// The GRDB `DatabaseMigrator` bringing a fresh/older store to `currentSchemaVersion`. - /// `eraseDatabaseOnSchemaChange` recreates the database whenever a registered - /// migration's definition changes — the rebuildable-cache "drop-and-recreate on schema - /// change" discipline (design §5; no users to preserve). + /// + /// `eraseDatabaseOnSchemaChange = FALSE` (S10.3, reversed from the earlier drop-and-recreate + /// posture): this DB holds NON-rebuildable USER data — playlists/entries, and the track + /// user-state columns `play_count`/`loved`/`rating`/`last_played`/`frecency_*` — NOT just a + /// cache of on-disk files. A break-it pass showed the old "wipe on any schema change" rule + /// silently destroyed that user data (and a separate never-erased store keyed by the reused + /// `tracks.id` rowid mis-resolved playlists after a rebuild — worse). So migrations are + /// **additive-only** and every shipped migration body is **frozen** (a schema change is a NEW + /// appended migration — proven-safe: appending never erases). The DERIVED cache (scan-built + /// track/album/artist/genre/FTS rows) is still rebuildable — by a RE-SCAN (delete rows + + /// re-scan), not by wiping the file. Dev reset: add a migration, or delete the DB file by hand. static func makeMigrator(appBuild: String?) -> DatabaseMigrator { var migrator = DatabaseMigrator() - migrator.eraseDatabaseOnSchemaChange = true + migrator.eraseDatabaseOnSchemaChange = false migrator.registerMigration(Schema.MigrationID.v1) { db in try Schema.migrateV0toV1(db, appBuild: appBuild, timestamp: nowSeconds()) } diff --git a/Sources/LibraryStore/Schema.swift b/Sources/LibraryStore/Schema.swift index f175696..9f6f79d 100644 --- a/Sources/LibraryStore/Schema.swift +++ b/Sources/LibraryStore/Schema.swift @@ -51,8 +51,8 @@ public enum Schema { /// v1 → v2: the `tracks_fts` FTS5 table + backfill (S9.2). public static let v2 = "v2-fts5" /// v2 → v3: the `playlists` + `playlist_entries` tables + the seeded built-in - /// "current" queue playlist (S10.1). Durability across schema change is DEFERRED - /// (design §0.1): `eraseDatabaseOnSchemaChange` stays true pre-R1. + /// "current" queue playlist (S10.1). User data now DURABLE across schema change + /// (S10.3): `eraseDatabaseOnSchemaChange = false` + additive-only migrations. public static let v3 = "v3-playlists" /// v3 → v4: `tracks.frecency_score` + `tracks.frecency_rank` (+ index) for the /// Recently-Played frecency ordering (S10.6). ADDITIVE (ALTER ADD COLUMN); play counts diff --git a/Sources/VerifyLibraryStore/ChecksCorruption.swift b/Sources/VerifyLibraryStore/ChecksCorruption.swift index 9f2b066..0f7e73f 100644 --- a/Sources/VerifyLibraryStore/ChecksCorruption.swift +++ b/Sources/VerifyLibraryStore/ChecksCorruption.swift @@ -215,57 +215,54 @@ func checkRestartDurability(number: Int, url: URL) async -> Bool { } } -// MARK: - ERASE — eraseDatabaseOnSchemaChange recreates on a migration-body change +// MARK: - ADDITIVE — an appended migration PRESERVES user data (eraseDatabaseOnSchemaChange = false) -/// ERASE: the store sets `eraseDatabaseOnSchemaChange = true` (rebuildable cache; no users), so -/// changing a registered migration's DEFINITION must recreate the database from scratch, not -/// attempt an in-place alter. Proven directly on a GRDB `DatabaseMigrator` with that config: -/// migrate + seed under one migration body, then reopen with the SAME identifier but a CHANGED -/// body — the seeded rows are gone and the new schema is in force (locks the "drop-and-recreate -/// on schema change" discipline against a future GRDB/config regression). -func checkEraseOnSchemaChange(number: Int, url: URL) async -> Bool { +/// ADDITIVE: the store sets `eraseDatabaseOnSchemaChange = false` (S10.3) because it holds +/// non-rebuildable USER data — playlists/entries + the track user-state columns +/// (`play_count`/`loved`/`rating`/`last_played`/`frecency_*`). So a schema change must be an +/// APPENDED migration that PRESERVES existing rows, never a wipe-and-recreate (a break-it pass +/// showed the old erase-on-schema-change rule silently destroyed that user data). Proven directly +/// on a GRDB `DatabaseMigrator` configured like production: seed under migration `m1`, then reopen +/// with `m1` FROZEN plus an APPENDED `m2` — the seeded row SURVIVES and `m2`'s new table appears. +/// Locks the additive-only posture against a future regression that flips the flag back to `true`. +func checkAdditiveMigrationPreservesData(number: Int, url: URL) async -> Bool { + func migrator(withM2: Bool) -> DatabaseMigrator { + var mig = DatabaseMigrator() + mig.eraseDatabaseOnSchemaChange = false + mig.registerMigration("m1") { db in + try db.execute(sql: "CREATE TABLE demo(id INTEGER PRIMARY KEY, v TEXT);") + try db.execute(sql: "INSERT INTO demo(v) VALUES ('kept');") + } + if withM2 { + mig.registerMigration("m2") { db in + try db.execute(sql: "CREATE TABLE demo2(id INTEGER PRIMARY KEY);") + } + } + return mig + } do { do { - var migrator = DatabaseMigrator() - migrator.eraseDatabaseOnSchemaChange = true - migrator.registerMigration("demo") { db in - try db.execute(sql: "CREATE TABLE demo(id INTEGER PRIMARY KEY, v TEXT);") - try db.execute(sql: "INSERT INTO demo(v) VALUES ('original');") - } let queue = try DatabaseQueue(path: url.path) - try migrator.migrate(queue) - let count = try await queue.read { db in try Int.fetchOne(db, sql: "SELECT count(*) FROM demo;") ?? -1 } - guard count == 1 else { - printFail(number, "eraseOnSchemaChange: setup seeded \(count) rows, expected 1"); return false - } - } - // Reopen with the SAME identifier but a CHANGED body (an added column) → GRDB detects the - // schema mismatch and recreates the DB from scratch rather than altering in place. - var changed = DatabaseMigrator() - changed.eraseDatabaseOnSchemaChange = true - changed.registerMigration("demo") { db in - try db.execute(sql: "CREATE TABLE demo(id INTEGER PRIMARY KEY, v TEXT, extra INTEGER);") + try migrator(withM2: false).migrate(queue) } + // Reopen: `m1` FROZEN + an APPENDED `m2`. With erase=false, GRDB runs only the new `m2`, + // leaving `m1`'s seeded rows intact — the opposite of the former drop-and-recreate. let queue = try DatabaseQueue(path: url.path) - try changed.migrate(queue) - let rowCount = try await queue.read { db in try Int.fetchOne(db, sql: "SELECT count(*) FROM demo;") ?? -1 } - var hasExtraColumn = true - do { - _ = try await queue.read { db in try Int.fetchOne(db, sql: "SELECT count(extra) FROM demo;") } - } catch { - hasExtraColumn = false + try migrator(withM2: true).migrate(queue) + let kept = try await queue.read { db in try String.fetchOne(db, sql: "SELECT v FROM demo LIMIT 1;") } + let hasDemo2 = try await queue.read { db in + try Bool.fetchOne(db, sql: "SELECT 1 FROM sqlite_master WHERE type='table' AND name='demo2';") ?? false } - guard rowCount == 0, hasExtraColumn else { - printFail(number, "eraseOnSchemaChange: DB not recreated " - + "(rows=\(rowCount), new column present=\(hasExtraColumn))") + guard kept == "kept", hasDemo2 else { + printFail(number, "additive-preserve: appended migration wiped or skipped " + + "(kept=\(kept ?? "nil"), demo2=\(hasDemo2))") return false } - printPass(number, "eraseOnSchemaChange: changing a registered migration's body recreated the DB " - + "(old rows dropped, new schema applied) — locks the rebuildable-cache " - + "'drop-and-recreate on schema change' discipline") + printPass(number, "additive-preserve: appending a migration PRESERVES seeded user data and runs the " + + "new migration (erase=false) — locks the additive-only posture") return true } catch { - printFail(number, "eraseOnSchemaChange threw: \(error)") + printFail(number, "additive-preserve threw: \(error)") return false } } diff --git a/Sources/VerifyLibraryStore/main.swift b/Sources/VerifyLibraryStore/main.swift index 717faba..193170e 100644 --- a/Sources/VerifyLibraryStore/main.swift +++ b/Sources/VerifyLibraryStore/main.swift @@ -183,11 +183,11 @@ func allCheckCases() -> [CheckCase] { } /// GRDB hardening (post-adoption follow-ups): single-pool DatabasePool concurrency + -/// eraseDatabaseOnSchemaChange recreate-on-body-change guard. +/// the additive-only (erase=false) data-preservation guard (S10.3). func hardeningCheckCases() -> [CheckCase] { [ CheckCase(label: "sp-single-pool-concurrency", run: checkSinglePoolConcurrency), - CheckCase(label: "erase-on-schema-change", run: checkEraseOnSchemaChange), + CheckCase(label: "additive-preserve-schema-bump", run: checkAdditiveMigrationPreservesData), CheckCase(label: "foreign-schema-rebuild", run: checkForeignSchemaRebuild), ] } diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md index 1b70459..58ed0de 100644 --- a/docs/sprints/s10-3-playlists-ux-design.md +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -1,17 +1,19 @@ -# S10.3 — Playlists UX — design (rev. 4 — LOCKED, buildable) +# S10.3 — Playlists UX — design (rev. 5 — LOCKED, buildable) -Last R1 gate. Research-grounded ([memo](s10-3-playlists-ux-research.md)) + three SME/Fool review rounds + founder decisions (2026-07-15). **Smart playlists DECOUPLED to post-R1** (see §Deferred). R1 scope: **static playlists + playlist folders + sidebar IA + Play/Next/Queue verbs with undo + dead-file handling.** Stories US-PLIST-02/-03/-04/-08 + folders. +Last R1 gate. Research-grounded ([memo](s10-3-playlists-ux-research.md)) + three SME/Fool review rounds + a QA break-it that overturned the store decision + founder decisions (2026-07-15). **Smart playlists DECOUPLED to post-R1** (see §Deferred). R1 scope: **static playlists + playlist folders + sidebar IA + Play/Next/Queue verbs with undo + dead-file handling.** Stories US-PLIST-02/-03/-04/-08 + folders. ## Locked decisions (founder) - **D-IA:** playlists live in a **dedicated sidebar section** (not a 5th browse category). Research norm + resolves the mutable-vs-read-only coherence concern. -- **D-store:** **separate `user-data.sqlite3` store** for playlists/folders/entries (additive-only, `eraseDatabaseOnSchemaChange = false`); `library.sqlite3` stays the nuke-and-rebuild cache. §2. +- **D-store (rev.5, REVERSED):** ~~separate `user-data.sqlite3` store.~~ **ONE store: keep playlists in `library.sqlite3`, and STOP erasing user data** — `LibraryStore.makeMigrator` now sets `eraseDatabaseOnSchemaChange = FALSE`; schema changes are **additive-only, frozen-body** migrations. A QA break-it (qa-expert + Fool) proved the separate-store split was BROKEN: its soft ref was the reused `tracks.id` rowid, which a routine library rebuild reassigns → playlists silently pointed at the WRONG track (worse than a clean wipe); and it protected only playlists while `play_count`/`loved`/`rating`/`frecency` — equally unrebuildable — stayed in the erased cache. One store keeps the FK `ON DELETE CASCADE` (deleted track → dropped entry, atomic), id stability, US-PLIST-08 (move-match keeps id), and protects ALL user data uniformly. §2. See [[feedback-delete-rebuild-dev-db]] (scoped/reversed). - **D-names:** playlist names are **globally unique** (keep `UNIQUE(name) WHERE is_builtin=0`). - **D-folder-delete:** deleting a playlist folder **deletes its contents** (playlists + subfolders), guarded by an **undo** (snapshot the subtree for restore). Strict "folder owns its contents". - **D-play:** **Play (replace queue) / Play Next / Add to Queue**; Play-replace is **reversible** via a "Restore previous queue" undo toast. - **D-smart:** smart/auto playlists are a **post-R1 fast-follow** (appending their migration later is free + lossless — §Deferred preserves the vetted design). -## 0. Corrected premise (was rev.3 §0.2 — WRONG) -Rev.3 claimed "adding folders/smart tables wipes playlists." **False** (architect + Fool, verified against GRDB source + the repo's own v4). `eraseDatabaseOnSchemaChange` erases only on a **downgrade** or an **edited already-shipped migration body** — never on **appending** a new migration. v4 (frecency) appended after v3 (playlists) and preserved data. The real risk is *whole-file collateral erase* from a future edited-migration or a corruption/quarantine rebuild — which **D-store** structurally removes by putting user data in its own never-erased file. +## 0. Store-decision history (why rev.5 lands on one store) +1. **rev.3 §0.2 (WRONG):** "adding tables wipes playlists" — false; appending a migration never erases (verified vs GRDB source + the repo's own additive v4). +2. **rev.4 (separate store):** to survive a *whole-file* erase (an edited shipped migration, or a corruption/quarantine rebuild), put user data in its own never-erased `user-data.sqlite3`. +3. **rev.5 (QA break-it overturned it — LOCKED):** the separate store's cross-store reference was `tracks.id`, a bare rowid (`INTEGER PRIMARY KEY`, no `AUTOINCREMENT`) that a library rebuild REASSIGNS in scan order → a `user-data` entry for id 5 resolves to a *different* file → **playlists silently show the wrong songs** (worse than the clean wipe the split was meant to prevent), and the sweep can't catch a *reused* id. It also protected only playlists while `play_count`/`loved`/`rating`/`frecency` (equally unrebuildable, and integrated as indexed `tracks` columns the browse `ORDER BY`s — so not movable) stayed exposed. **Root cause:** the library DB was never a *pure* cache — it holds user data too. **Fix:** don't erase it. One store, `eraseDatabaseOnSchemaChange = false`, additive-only; the derived cache is rebuilt by re-scan, not by wiping the file. ## 1. IA — sidebar Playlists section (D-IA) A dedicated "Playlists" section in the left sidebar, **separate** from the Songs/Albums/Artists/Genres category rail and the Music Folders accordion. **Scrollable, shows the full set** (Audirvana's 2-row truncation is a documented "dealbreaker"). Folders render as a **flattened disclosure tree**. Selecting a playlist → detail in the content area. @@ -22,12 +24,12 @@ A dedicated "Playlists" section in the left sidebar, **separate** from the Songs Re-implement ↑/↓ + ←/→ (collapse/expand) via `.onKeyPress` + `@FocusState` (as `PlaylistItemList` does); re-create the selection capsule with `DesignSystem.Color.rowSelected`. - **Folder tree (swiftui-pro):** **flatten the expanded tree into a depth-annotated array** (`SidebarNode { id, kind, depth }`) and render flat — do NOT use `OutlineGroup`/`DisclosureGroup` (they re-introduce `List` dead-drops OR break LazyVStack laziness + nest drop hit-regions). Expansion = `Set` **on the model** (tab switch destroys the view), persisted (`@AppStorage`-JSON, matching `library.foldersExpanded.v1`). Soft-cap indent (~5 levels). -## 2. Architecture — the separate user-data store (D-store, load-bearing) -- **`UserDataStore`** — a second GRDB store (`user-data.sqlite3`) with its own `DatabaseMigrator`, **`eraseDatabaseOnSchemaChange = false`**, **additive-only frozen-shipped-body discipline**. Holds `playlists`, `playlist_entries`, `playlist_folders` (+ post-R1 `playlist_rules`), and the built-in "current" queue. `library.sqlite3` is unchanged (cache, erase=true). -- **Migrate the S10.1/S10.2 playlist tables out of `library.sqlite3` into `UserDataStore`** (chunk **A0**). Repoint the two shipped S10.2 consumers: `AudioViewModel+QueueMirror.swift` (`replaceEntries`/`currentPlaylistID`) + `AudioViewModel+QueueHydration.swift`. The `LibraryStore+Playlists.swift` DAO moves to the new store. -- **No cross-file FK** (SQLite can't). `playlist_entries.track_id` is a **soft reference** (durable `Int64`). The old `ON DELETE CASCADE` (track delete → drop entry) becomes an **app-layer sweep**: on `library.libraryRevision` change, delete `playlist_entries` whose `track_id` no longer exists in `library.tracks` (this is exactly the founder's "track deleted/moved → remove from playlist" rule; a moved+re-matched track keeps its id, so it survives — US-PLIST-08). Track *identity* stays authoritative in `library.sqlite3`; loose-add writes the track row there, the entry soft-refs the returned id. -- **`PlaylistsModel`** — new `@MainActor @Observable` composition-root peer over `UserDataStore` (+ `library` for track identity/display, `audio` for queue verbs). Owns the folder/playlist tree, open detail, verbs. **Per-surface epoch tokens** (tree / detail-entries) like `LibraryBrowseModel`; refresh on mutation + on `libraryRevision` (for the orphan sweep). Nav (`SidebarSelection`, `LibraryRoute.playlist(Int64)`) on `LibraryBrowseModel`. -- **Reference-add = TYPE-LEVEL (US-PLIST-04):** `LibraryTrackDragItem { trackID: Int64 }` (declared UTType); `addTrackToPlaylist(trackID:playlistID:)` → `appendEntry` only. Can't move a file known by id. No `FileManager` move/copy in the playlist path (strict-gate grep backstop). Pure `PlaylistDropRouter`. +## 2. Architecture — one store, never erase user data (D-store rev.5) +- **`library.sqlite3` stops erasing (DONE, A0):** `LibraryStore.makeMigrator` → `eraseDatabaseOnSchemaChange = false`; migrations are **additive-only, frozen-shipped-body**. Playlists (`playlists`/`playlist_entries`, S10.1) STAY in this store; folders (`playlist_folders`) + post-R1 `playlist_rules` are **appended** as new migrations (v5+). Guarded by the `additive-preserve-schema-bump` VerifyLibraryStore check. Derived cache = rebuilt by re-scan, never by wiping the file. +- **The deleted-track → dropped-entry rule is the existing same-file FK** `playlist_entries.track_id → tracks.id ON DELETE CASCADE` — atomic, no app-layer sweep, no cross-store anything. A folder-removal still keeps playlist-referenced tracks (the S10.1 `unreferencedTrackIDs` Gate-1, unchanged). US-PLIST-08 works because move-match preserves `tracks.id` in place (same file). `tracks.id` is durable because the file is never rebuilt out from under it. +- **`PlaylistsModel`** — new `@MainActor @Observable` composition-root peer over **`library.store`** (the playlist DAO already lives there) + `audio` for queue verbs. Owns the folder/playlist tree, open detail, verbs. **Per-surface epoch tokens** (tree / detail-entries) like `LibraryBrowseModel`; authoritative re-read after each mutation. Nav (`SidebarSelection`, `LibraryRoute.playlist(Int64)`) on `LibraryBrowseModel`. (No repoint of the S10.2 queue consumers — they already use `library.store`.) +- **Reference-add = TYPE-LEVEL (US-PLIST-04):** `LibraryTrackDragItem { trackID: Int64 }` (declared UTType); `addTrackToPlaylist(trackID:playlistID:)` → `appendEntry` only. Can't move a file known by id. No `FileManager` move/copy in the playlist path (strict-gate grep backstop). Pure `PlaylistDropRouter`. Loose-file add uses the existing `addLooseFileToPlaylist` (one txn, same store). +- **Backup/export** of the whole DB (covers playlists + user-state against the corruption/quarantine path) — noted follow-up, out of R1 scope. - **Built-in "current" invisible + inert:** filter `is_builtin=0` everywhere (pure `PlaylistBrowseVisibility`); DAO-reject mutations on it. - **Dead / loose files:** entry resolution → `.ok | .missing`; **play SKIPS `.missing` (never halts)**; per-row unavailable indicator; Locate + bulk "Remove missing" + launch orphan-sweep. @@ -49,13 +51,13 @@ Adjacency-list `playlist_folders(id, parent_id NULL self-ref, name, position, cr ## 5. QA plan (R1) - **Pure (`swift test`, LibraryBrowseKit):** `PlaylistDropRouter` (drop → add-ops only, never move); `PlaylistBrowseVisibility` (built-in excluded); `PlaylistAddDecision` (already-present → toast; empty-play no-op; multi-select order/dedupe); loose-entry resolution → `.missing` (retain, skip); folder cycle-guard (self + descendant). - **Strict-gate grep:** playlist-drop handler references no `moveItem`/`copyItem`. -- **VerifyUserDataStore / VerifyLibraryStore (headless, new):** **`ud-survives-schema-bump`** (GREEN-GATE, load-bearing: seed playlists+folders → register a throwaway additive migration → assert every user row survives — proves D-store); `pl-move-membership-survives` (real scanner, US-PLIST-08); **`pl-orphan-sweep`** (delete a track in library → the cross-store sweep drops its entries; moved track keeps membership); `pl-reorder-isolation`; `pl-folder-cascade-delete` + cycle-reject; `pl-explain-plan` (list + entries use indexes); `pl-write-during-scan`; built-in mutation rejected. -- **qa-expert + Fool break-it** post-impl: wrong-target drop; delete open-detail playlist/folder; rename race; reorder during sweep; duplicate flood; hundreds of playlists + deep nesting; play a moved file (skip+retain); cross-playlist drag = copy; built-in never exposed; cross-store crash between loose-add's two writes (→ harmless orphan/.missing). +- **VerifyLibraryStore (headless):** **`additive-preserve-schema-bump`** (GREEN-GATE, DONE, A0: an appended migration preserves seeded data under erase=false — proves the store posture); `pl-move-membership-survives` (real scanner, US-PLIST-08); `pl-reorder-isolation`; `pl-folder-cascade-delete` (same-file FK cascade) + cycle-reject; `pl-explain-plan` (list + entries use indexes); `pl-write-during-scan`; built-in mutation rejected. (No `pl-orphan-sweep` — the same-file `ON DELETE CASCADE` handles deleted-track cleanup atomically; the existing `pl-file-gone-drop` check already covers it.) +- **qa-expert + Fool break-it** post-impl: wrong-target drop; delete open-detail playlist/folder; rename race; reorder during a scan-sweep; duplicate flood; hundreds of playlists + deep nesting; play a moved file (skip+retain); cross-playlist drag = copy; built-in never exposed. - **Founder by-hand:** create/rename/delete (playlist, folder + undo); drag Songs→playlist (verify no file moved on disk); nest folders; the three play verbs + restore-queue undo; dead-file skip + remove-missing. ## 6. Chunking (phased, build-gated) -- **A0** — `UserDataStore` (separate additive-only store) + migrate playlist tables out of `library.sqlite3` + repoint S10.2 consumers + `ud-survives-schema-bump` green-gate. *(The load-bearing refactor — do first, gate hard.)* -- **A** — `playlist_folders` schema + folder DAO (CRUD/reparent/cycle-guard/cascade-delete-with-subtree-snapshot) + `PlaylistsModel` peer + cross-store orphan sweep on `libraryRevision` (`pl-orphan-sweep`). +- **A0** — ✅ DONE: `library.sqlite3` → `eraseDatabaseOnSchemaChange = false` (additive-only, protects playlists + track user-state) + the `additive-preserve-schema-bump` green-gate. *(The separate-store detour was built then reverted per the QA break-it — §0.)* +- **A** — `playlist_folders` schema as an **additive v5 migration** + folder DAO (CRUD/reparent/cycle-guard/cascade-delete-with-subtree-snapshot for undo) + `PlaylistsModel` peer over `library.store`. (Deleted-track cleanup is the same-file FK cascade — no sweep needed.) - **B** — sidebar Playlists section (flat first): one ScrollView/LazyVStack, unified selection enum, built-in filtered, scroll, select→detail, create/inline-rename/delete. - **C** — `PlaylistDetailList` (queue-row reuse): play (verbs + undo), remove, reorder; `ForEach` on entry id. - **D** — folders UI: flattened disclosure tree, create/rename/delete(+undo), drag-reparent, cycle-guard, depth cap. @@ -65,9 +67,9 @@ Adjacency-list `playlist_folders(id, parent_id NULL self-ref, name, position, cr ## 7. Open sub-decisions (recommend + proceed unless vetoed) - **Play a folder** — *recommended:* plays all descendant playlists' tracks in tree order (bounded by a queue-size safety cap). Same for a multi-playlist selection. -- **Backup/export of user data** — *recommended:* out of R1 scope, but note as the belt-and-braces follow-up (covers a corrupt `user-data.sqlite3` — the one thing the store-split alone can't). Not blocking. +- **Backup/export of user data** — *recommended:* out of R1 scope, but note as the belt-and-braces follow-up (the corruption/quarantine rebuild of `library.sqlite3` still loses user data — erase=false only protects the schema-change path, not corruption). Not blocking. --- ## Deferred to post-R1 — Smart / auto playlists (design preserved) -Vetted but decoupled (D-smart). A later additive `user-data` migration adds `playlists.is_smart` + a normalized **`playlist_rules`** table; membership is **derived** (a pure `SmartRuleSQLBuilder` → `WHERE` over `library.tracks`, run when the smart list is opened — rules read from `user-data`, query runs on `library`; no cross-store JOIN). Key review points to honor when built: **fields are predicate-shaped** (genre → `EXISTS(track_genres…)`, artist/album → id sub-select — NOT raw columns); **field+op are closed enums, value always bound** (injection-safe); **live match-count** in the builder + mandatory **limit** (default ~1000) so play-enqueue is bounded; **split the EXPLAIN tripwire** (index-assert equality/range; tolerate `LIKE '%x%'` scan); read-only detail (reuse `PlaylistItemRow`'s nil-`dragPayload` path); polymorphic value editor as a `@ViewBuilder` switch (not `AnyView`) with a draft/commit-on-Save (not live-bound); counts are eventually-consistent (refresh on open + `libraryRevision`, accept slightly-stale sidebar counts). Criteria v1 set: artist/album/genre/year/rating/loved/play_count/last_played/date_added/duration/format/sample_rate/bit_depth/frecency; ops =/≠/contains/>// Date: Wed, 15 Jul 2026 15:58:24 +0530 Subject: [PATCH 07/15] =?UTF-8?q?feat(s10.3=20A):=20playlist=20folders=20?= =?UTF-8?q?=E2=80=94=20additive=20v5=20migration=20+=20folder=20DAO=20+=20?= =?UTF-8?q?checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema v5 (additive, durable under erase=false): playlist_folders adjacency-list tree (parent_id self-ref, ON DELETE CASCADE) + playlists.folder_id (ON DELETE CASCADE). Deleting a folder cascades to subfolders + its playlists + their entries — 'folder owns its contents' (D-folder-delete). Folder DAO (LibraryStore+PlaylistFolders): folders()/createFolder/renameFolder/ setPlaylistFolder + reparentFolder with an in-txn WITH RECURSIVE cycle-guard (a folder can't become its own ancestor) + deleteFolder that snapshots the whole subtree (folders+playlists+entries, value structs) BEFORE the cascade and restoreFolderSubtree that re-inserts it verbatim (ids preserved, deferred FKs) — the undo. Single store, no cross-store anything. VerifyLibraryStore: pl-folder-crud, pl-folder-reparent-cycle-reject, pl-folder-cascade-delete-restore (117/117). fullMigrator() helper + expectedTables + currentSchemaVersion bumped to v5. --- .../LibraryStore+PlaylistFolders.swift | 257 ++++++++++++++++++ .../LibraryStore/LibraryStore+Playlists.swift | 3 + Sources/LibraryStore/LibraryStore.swift | 3 + Sources/LibraryStore/Schema.swift | 45 ++- Sources/VerifyLibraryStore/Checks.swift | 3 + .../ChecksPlaylistFolders.swift | 125 +++++++++ Sources/VerifyLibraryStore/main.swift | 2 +- 7 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 Sources/LibraryStore/LibraryStore+PlaylistFolders.swift create mode 100644 Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift diff --git a/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift new file mode 100644 index 0000000..7d02fcb --- /dev/null +++ b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift @@ -0,0 +1,257 @@ +// LibraryStore+PlaylistFolders — the playlist-FOLDER DAO (S10.3, schema v5), GRDB-backed. +// +// Folders are an adjacency-list tree (`playlist_folders.parent_id` self-ref) holding playlists +// (`playlists.folder_id`) and subfolders. Same single-writer/one-txn discipline as the playlist +// DAO. Delete is "folder owns its contents" (D-folder-delete): a `DELETE` of the folder row +// CASCADEs to subfolders + the playlists it holds + their entries — the DAO snapshots the whole +// subtree BEFORE the delete and can re-insert it, which is what backs the UI's undo. Reparent is +// cycle-guarded inside the write txn (a folder can't become its own ancestor). + +import Foundation +import GRDB + +// MARK: - Value types + +/// A playlist-folder row. `parentID == nil` is a root-level folder. +public struct PlaylistFolder: Sendable, Identifiable, Equatable { + public let id: Int64 + public let parentID: Int64? + public let name: String + public let position: Int + public let createdAt: Int64 + + public init(id: Int64, parentID: Int64?, name: String, position: Int, createdAt: Int64) { + self.id = id + self.parentID = parentID + self.name = name + self.position = position + self.createdAt = createdAt + } +} + +extension PlaylistFolder: FetchableRecord { + /// Decodes a row projected as `id, parent_id, name, position, created_at`. + public init(row: Row) { + self.init(id: row[0], parentID: row[1], name: row[2] ?? "", + position: Int(row[3] as Int64), createdAt: row[4]) + } +} + +/// A captured folder subtree (the folder, its descendant folders, the playlists they hold, and +/// those playlists' entries) — snapshotted before a cascade delete so the UI can UNDO by +/// re-inserting it verbatim (ids preserved). `Sendable` value type; no `Database` escapes. +public struct PlaylistSubtreeSnapshot: Sendable, Equatable { + /// A playlist's raw restorable columns (the display `Playlist` omits `folder_id`). + public struct PlaylistRow: Sendable, Equatable { + public let id: Int64 + public let name: String + public let isBuiltin: Bool + public let folderID: Int64? + public let createdAt: Int64 + } + + public let folders: [PlaylistFolder] + public let playlists: [PlaylistRow] + public let entries: [PlaylistEntry] + + public var isEmpty: Bool { + folders.isEmpty && playlists.isEmpty && entries.isEmpty + } +} + +public extension LibraryStore { + // MARK: - SQL + + private static let selectFoldersSQL = + "SELECT id, parent_id, name, position, created_at FROM playlist_folders " + + "ORDER BY position ASC, name COLLATE NOCASE ASC, id ASC;" + private static let insertFolderSQL = + "INSERT INTO playlist_folders(parent_id, name, position, created_at) VALUES (?, ?, ?, ?);" + private static let maxFolderPositionSQL = + "SELECT COALESCE(MAX(position), -1) FROM playlist_folders WHERE parent_id IS ?;" + private static let renameFolderSQL = "UPDATE playlist_folders SET name = ? WHERE id = ?;" + private static let reparentFolderSQL = "UPDATE playlist_folders SET parent_id = ? WHERE id = ?;" + private static let deleteFolderSQL = "DELETE FROM playlist_folders WHERE id = ?;" + private static let folderExistsSQL = "SELECT 1 FROM playlist_folders WHERE id = ?;" + /// Distinguishes an absent playlist (→ `.notFound`) from the built-in (→ `.builtinImmutable`) + /// so `setPlaylistFolder` reports the right reason instead of a silent 0-row no-op. + private static let playlistIsBuiltinSQL = "SELECT is_builtin FROM playlists WHERE id = ?;" + /// Move a USER playlist into a folder (or to root, `folder_id = NULL`); never the built-in. + private static let setPlaylistFolderSQL = + "UPDATE playlists SET folder_id = ? WHERE id = ? AND is_builtin = 0;" + /// The subtree of folder ids rooted at `?` (inclusive), via a recursive descendant walk. + private static let subtreeFolderIDsSQL = """ + WITH RECURSIVE sub(id) AS ( + SELECT ? + UNION + SELECT pf.id FROM playlist_folders pf JOIN sub ON pf.parent_id = sub.id + ) + SELECT id FROM sub; + """ + + // MARK: - Reads + + /// Every folder, ordered by (position, name) — the caller builds the tree from `parentID`. + func folders() async throws -> [PlaylistFolder] { + try await dbWriter.read { db in try PlaylistFolder.fetchAll(db, sql: Self.selectFoldersSQL) } + } + + // MARK: - Lifecycle + + /// Create a folder under `parentID` (nil = root), appended at the end of its siblings. Throws + /// `.notFound` if `parentID` is given but absent. Folder names are NOT required unique + /// (organizational; only PLAYLIST names are globally unique — D-names). Returns the new id. + @discardableResult + func createFolder(name: String, parentID: Int64?) async throws -> Int64 { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw PlaylistMutationError.invalidName(name) } + return try await dbWriter.write { db in + if let parentID, try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [parentID]) == nil { + throw PlaylistMutationError.notFound(id: parentID) + } + let maxPos = try Int64.fetchOne(db, sql: Self.maxFolderPositionSQL, arguments: [parentID]) ?? -1 + try db.execute(sql: Self.insertFolderSQL, + arguments: [parentID, trimmed, maxPos + 1, LibraryStore.nowSeconds()]) + return db.lastInsertedRowID + } + } + + /// Rename a folder (empty/whitespace rejected). Throws `.notFound` for a missing id. + func renameFolder(id: Int64, to name: String) async throws { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw PlaylistMutationError.invalidName(name) } + try await dbWriter.write { db in + guard try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [id]) != nil else { + throw PlaylistMutationError.notFound(id: id) + } + try db.execute(sql: Self.renameFolderSQL, arguments: [trimmed, id]) + } + } + + /// Move a playlist into `folderID` (nil = root). Playlist-scoped, never the built-in "current". + /// Throws `.notFound` for an absent playlist and `.builtinImmutable` for the built-in (matching + /// the sibling DAO idiom — not a silent 0-row no-op); `.notFound` for a missing target folder. + func setPlaylistFolder(playlistID: Int64, folderID: Int64?) async throws { + try await dbWriter.write { db in + guard let isBuiltin = try Int64.fetchOne(db, sql: Self.playlistIsBuiltinSQL, arguments: [playlistID]) else { + throw PlaylistMutationError.notFound(id: playlistID) + } + guard isBuiltin == 0 else { throw PlaylistMutationError.builtinImmutable(id: playlistID) } + if let folderID, try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [folderID]) == nil { + throw PlaylistMutationError.notFound(id: folderID) + } + try db.execute(sql: Self.setPlaylistFolderSQL, arguments: [folderID, playlistID]) + } + } + + /// Reparent `id` under `newParentID` (nil = root). CYCLE-GUARDED inside the write txn: rejects + /// making a folder its own ancestor (target == the node, or target is in the node's subtree), + /// which would orphan a cycle. Throws `.notFound` for a missing node/parent, `.wouldCreateCycle` + /// for a cycle (so the UI can distinguish a bad move from a bad name). + func reparentFolder(id: Int64, newParentID: Int64?) async throws { + try await dbWriter.write { db in + guard try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [id]) != nil else { + throw PlaylistMutationError.notFound(id: id) + } + if let newParentID { + guard try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [newParentID]) != nil else { + throw PlaylistMutationError.notFound(id: newParentID) + } + // Cycle check: the new parent must NOT be inside the subtree rooted at `id`. + let subtree = try Set(Int64.fetchAll(db, sql: Self.subtreeFolderIDsSQL, arguments: [id])) + guard !subtree.contains(newParentID) else { + throw PlaylistMutationError.wouldCreateCycle(id: id, newParentID: newParentID) + } + } + try db.execute(sql: Self.reparentFolderSQL, arguments: [newParentID, id]) + } + } + + // MARK: - Delete (cascade) + undo snapshot + + /// Delete a folder and everything it owns. Snapshots the whole subtree (the folder + descendant + /// folders + the playlists they hold + those playlists' entries) BEFORE the `DELETE` (whose + /// `ON DELETE CASCADE` removes it all in one txn), and RETURNS the snapshot so the UI can undo + /// via `restoreFolderSubtree`. Throws `.notFound` for a missing id. + @discardableResult + func deleteFolder(id: Int64) async throws -> PlaylistSubtreeSnapshot { + try await dbWriter.write { db in + guard try Int64.fetchOne(db, sql: Self.folderExistsSQL, arguments: [id]) != nil else { + throw PlaylistMutationError.notFound(id: id) + } + let snapshot = try Self.snapshotSubtree(db, rootFolderID: id) + try db.execute(sql: Self.deleteFolderSQL, arguments: [id]) // CASCADE does the rest + return snapshot + } + } + + /// Capture the subtree rooted at `rootFolderID` as value structs (folders top-down so a restore + /// can insert parents before children even without deferred FKs; playlists + their entries). + private static func snapshotSubtree(_ db: Database, rootFolderID: Int64) throws -> PlaylistSubtreeSnapshot { + let folderIDs = try Int64.fetchAll(db, sql: subtreeFolderIDsSQL, arguments: [rootFolderID]) + let idList = folderIDs.map(String.init).joined(separator: ",") // ids are DB-derived Int64s, safe + // Folders, shallow-first (parents before children) so re-insert satisfies the self-FK. + let folders = try PlaylistFolder.fetchAll( + db, sql: "SELECT id, parent_id, name, position, created_at FROM playlist_folders " + + "WHERE id IN (\(idList)) ORDER BY (parent_id IS NOT NULL), id;" + ) + let playlists = try PlaylistSubtreeSnapshot.PlaylistRow.rows( + db, sql: "SELECT id, name, is_builtin, folder_id, created_at FROM playlists " + + "WHERE folder_id IN (\(idList));" + ) + let playlistIDList = playlists.map { String($0.id) }.joined(separator: ",") + let entries: [PlaylistEntry] = playlists.isEmpty ? [] : try PlaylistEntry.fetchAll( + db, sql: "SELECT id, playlist_id, track_id, position, added_at FROM playlist_entries " + + "WHERE playlist_id IN (\(playlistIDList)) ORDER BY playlist_id, position;" + ) + return PlaylistSubtreeSnapshot(folders: folders, playlists: playlists, entries: entries) + } + + /// Re-insert a previously-deleted subtree (UNDO), preserving every id. Runs with deferred FKs so + /// insertion order within the txn is unconstrained; the graph is internally consistent because + /// it was captured as a whole. A no-op for an empty snapshot. + /// + /// Best-effort for the IMMEDIATE-undo model the UI uses: if the world changed between the delete + /// and the undo (a freed rowid reissued → PK collision; a re-created playlist reusing the deleted + /// name → UNIQUE violation; a referenced track/parent deleted → deferred-FK failure at commit), + /// the single txn ROLLS BACK and this THROWS — it never leaves partial/corrupt state. Callers + /// should surface the throw, not swallow it. + func restoreFolderSubtree(_ snapshot: PlaylistSubtreeSnapshot) async throws { + guard !snapshot.isEmpty else { return } + try await dbWriter.write { db in + try db.execute(sql: "PRAGMA defer_foreign_keys = ON;") + for folder in snapshot.folders { + try db.execute( + sql: "INSERT INTO playlist_folders(id, parent_id, name, position, created_at) " + + "VALUES (?, ?, ?, ?, ?);", + arguments: [folder.id, folder.parentID, folder.name, folder.position, folder.createdAt] + ) + } + for playlist in snapshot.playlists { + try db.execute( + sql: "INSERT INTO playlists(id, name, is_builtin, folder_id, created_at) " + + "VALUES (?, ?, ?, ?, ?);", + arguments: [playlist.id, playlist.name, playlist.isBuiltin ? 1 : 0, + playlist.folderID, playlist.createdAt] + ) + } + for entry in snapshot.entries { + try db.execute( + sql: "INSERT INTO playlist_entries(id, playlist_id, track_id, position, added_at) " + + "VALUES (?, ?, ?, ?, ?);", + arguments: [entry.id, entry.playlistID, entry.trackID, entry.position, entry.addedAt] + ) + } + } + } +} + +private extension PlaylistSubtreeSnapshot.PlaylistRow { + /// Fetch raw playlist restore-rows for the snapshot. + static func rows(_ db: Database, sql: String) throws -> [Self] { + try Row.fetchAll(db, sql: sql).map { + Self(id: $0[0], name: $0[1] ?? "", isBuiltin: ($0[2] as Int64) != 0, + folderID: $0[3], createdAt: $0[4]) + } + } +} diff --git a/Sources/LibraryStore/LibraryStore+Playlists.swift b/Sources/LibraryStore/LibraryStore+Playlists.swift index 02134f8..9849034 100644 --- a/Sources/LibraryStore/LibraryStore+Playlists.swift +++ b/Sources/LibraryStore/LibraryStore+Playlists.swift @@ -80,6 +80,9 @@ public enum PlaylistMutationError: Error, Sendable, Equatable { case notFound(id: Int64) /// Empty / whitespace-only name, or the reserved built-in name "current". case invalidName(String) + /// A folder reparent that would make a folder its own ancestor (target == the node or is in + /// its subtree). Distinct from `.invalidName` so the UI can message the move, not the name. + case wouldCreateCycle(id: Int64, newParentID: Int64) } // MARK: - Row decoding diff --git a/Sources/LibraryStore/LibraryStore.swift b/Sources/LibraryStore/LibraryStore.swift index 8635193..e7a844d 100644 --- a/Sources/LibraryStore/LibraryStore.swift +++ b/Sources/LibraryStore/LibraryStore.swift @@ -382,6 +382,9 @@ public final class LibraryStore: Sendable { migrator.registerMigration(Schema.MigrationID.v4) { db in try Schema.migrateV3toV4(db, appBuild: appBuild, timestamp: nowSeconds()) } + migrator.registerMigration(Schema.MigrationID.v5) { db in + try Schema.migrateV4toV5(db, appBuild: appBuild, timestamp: nowSeconds()) + } return migrator } diff --git a/Sources/LibraryStore/Schema.swift b/Sources/LibraryStore/Schema.swift index 9f6f79d..86a3296 100644 --- a/Sources/LibraryStore/Schema.swift +++ b/Sources/LibraryStore/Schema.swift @@ -32,7 +32,7 @@ import GRDB /// means: register a new migration in `LibraryStore.makeMigrator` (with a new `Schema.MigrationID`) /// whose body writes `schema_info` at the new version, AND bump this constant — the migration /// creates/backfills the schema; this constant is the value the harness expects to read back. -public let currentSchemaVersion = 4 +public let currentSchemaVersion = 5 /// The reserved "unknown artist" sentinel rowid seeded at v1 for the M1 album key. public let unknownArtistID: Int64 = 0 @@ -60,6 +60,11 @@ public enum Schema { /// nullable so a legacy/never-scored played row sorts last (NULLs last in DESC) rather /// than corrupting the order. public static let v4 = "v4-frecency" + /// v4 → v5: playlist FOLDERS (S10.3). ADDITIVE — a `playlist_folders` adjacency-list tree + /// + a nullable `playlists.folder_id`. Deleting a folder CASCADEs to its subfolders + + /// playlists + entries (D-folder-delete "folder owns its contents"; undo is an app-layer + /// subtree snapshot). This migration is now DURABLE (erase=false) — it never wipes. + public static let v5 = "v5-playlist-folders" } /// The complete set of `CREATE` statements for schema v1, ordered so a @@ -197,6 +202,8 @@ public enum Schema { "tracks", "track_genres", // v3 (S10.1): playlists + ordered entries. "playlists", "playlist_entries", + // v5 (S10.3): playlist folders (nesting tree). + "playlist_folders", ] /// Migrate an empty (v0) database to v1: create every table + index, then seed @@ -414,3 +421,39 @@ public enum Schema { ) } } + +// MARK: - Schema v5 (S10.3 playlist folders) + +/// v5 schema lives in an extension to keep the main `Schema` body under the type-body-length +/// limit; the members are still `Schema.createV5Statements` / `Schema.migrateV4toV5`. +public extension Schema { + /// v5 (S10.3): playlist FOLDERS — an adjacency-list tree (`parent_id` self-ref) plus a nullable + /// `playlists.folder_id`. Both FKs are `ON DELETE CASCADE` so deleting a folder removes its + /// subfolders + the playlists it holds (+ their entries via the existing `playlist_entries` + /// cascade) — the "folder owns its contents" delete (undo = an app-layer subtree snapshot). + /// `folder_id` is added by `ALTER ADD COLUMN` with a NULL default, so the `REFERENCES` clause + /// is permitted (SQLite allows it only when the added column defaults to NULL). + static let createV5Statements: [String] = [ + """ + CREATE TABLE playlist_folders ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES playlist_folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL); + """, + "ALTER TABLE playlists ADD COLUMN folder_id INTEGER REFERENCES playlist_folders(id) ON DELETE CASCADE;", + "CREATE INDEX idx_playlist_folders_parent ON playlist_folders(parent_id);", + "CREATE INDEX idx_playlists_folder ON playlists(folder_id);", + ] + + /// Migrate v4 → v5: add the playlist-folders tree + `playlists.folder_id`. Additive — existing + /// playlists stay at the root (`folder_id = NULL`). Durable (erase=false): never wipes. + static func migrateV4toV5(_ db: Database, appBuild: String?, timestamp: Int64) throws { + for statement in createV5Statements { + try db.execute(sql: statement) + } + try writeSchemaInfo(db, version: 5, appBuild: appBuild, + createdAt: timestamp, migratedAt: timestamp) + } +} diff --git a/Sources/VerifyLibraryStore/Checks.swift b/Sources/VerifyLibraryStore/Checks.swift index d1a2e69..543b2a4 100644 --- a/Sources/VerifyLibraryStore/Checks.swift +++ b/Sources/VerifyLibraryStore/Checks.swift @@ -65,6 +65,9 @@ func fullMigrator() -> DatabaseMigrator { migrator.registerMigration(Schema.MigrationID.v4) { db in try Schema.migrateV3toV4(db, appBuild: "verify", timestamp: testTimestamp) } + migrator.registerMigration(Schema.MigrationID.v5) { db in + try Schema.migrateV4toV5(db, appBuild: "verify", timestamp: testTimestamp) + } return migrator } diff --git a/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift b/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift new file mode 100644 index 0000000..2304389 --- /dev/null +++ b/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift @@ -0,0 +1,125 @@ +// ChecksPlaylistFolders — S10.3 playlist-FOLDER DAO checks (schema v5). Same idiom as +// ChecksPlaylists (Bool return, numbered PASS/FAIL, temp DBs). Registered via +// playlistFolderCheckCases() in main.swift. + +import Foundation +import LibraryStore + +// MARK: - Registration + +func playlistFolderCheckCases() -> [CheckCase] { + [ + CheckCase(label: "pl-folder-crud", run: checkFolderCRUD), + CheckCase(label: "pl-folder-reparent-cycle-reject", run: checkFolderReparentCycle), + CheckCase(label: "pl-folder-cascade-delete-restore", run: checkFolderCascadeDeleteRestore), + ] +} + +// MARK: - Checks + +/// pl-folder-crud: create root + nested folders; parentage + rename; missing-parent rejected. +func checkFolderCRUD(number: Int, url: URL) async -> Bool { + do { + let store = try await LibraryStore(url: url, appBuild: "verify") + let jazz = try await store.createFolder(name: "Jazz", parentID: nil) + let bebop = try await store.createFolder(name: "Bebop", parentID: jazz) + let all = try await store.folders() + guard let jazzRow = all.first(where: { $0.id == jazz }), jazzRow.parentID == nil, + let bebopRow = all.first(where: { $0.id == bebop }), bebopRow.parentID == jazz else { + printFail(number, "folder parentage wrong: \(all.map { ($0.id, $0.parentID as Any) })"); return false + } + try await store.renameFolder(id: jazz, to: "Jazz & Blues") + guard try await store.folders().first(where: { $0.id == jazz })?.name == "Jazz & Blues" else { + printFail(number, "folder rename not reflected"); return false + } + var missingParentRejected = false + do { + _ = try await store.createFolder(name: "Orphan", parentID: 999_999) + } catch PlaylistMutationError.notFound { + missingParentRejected = true + } + guard missingParentRejected else { + printFail(number, "createFolder under a missing parent not rejected"); return false + } + printPass(number, "folder CRUD: root + nested create, parentage correct, rename works, missing-parent rejected") + return true + } catch { printFail(number, "pl-folder-crud threw: \(error)"); return false } +} + +/// pl-folder-reparent-cycle-reject: a folder cannot become its own ancestor; a valid move works. +func checkFolderReparentCycle(number: Int, url: URL) async -> Bool { + do { + let store = try await LibraryStore(url: url, appBuild: "verify") + let top = try await store.createFolder(name: "A", parentID: nil) + let mid = try await store.createFolder(name: "B", parentID: top) + let leaf = try await store.createFolder(name: "C", parentID: mid) + // `top` under `leaf` would create a cycle (`leaf` is a descendant of `top`) → reject. + var cycleRejected = false + do { + try await store.reparentFolder(id: top, newParentID: leaf) + } catch PlaylistMutationError.wouldCreateCycle { + cycleRejected = true + } + // `top` under itself → reject. + var selfRejected = false + do { + try await store.reparentFolder(id: top, newParentID: top) + } catch PlaylistMutationError.wouldCreateCycle { + selfRejected = true + } + guard cycleRejected, selfRejected else { + printFail(number, "cycle not rejected (descendant=\(cycleRejected) self=\(selfRejected))"); return false + } + // `leaf` under `top` is valid (`top` is NOT in `leaf`'s subtree). + try await store.reparentFolder(id: leaf, newParentID: top) + guard try await store.folders().first(where: { $0.id == leaf })?.parentID == top else { + printFail(number, "valid reparent (leaf under top) not reflected"); return false + } + printPass(number, "reparent cycle-guard: self + descendant moves rejected, a valid move succeeds") + return true + } catch { printFail(number, "pl-folder-reparent-cycle-reject threw: \(error)"); return false } +} + +/// pl-folder-cascade-delete-restore: deleting a folder CASCADEs its subtree (subfolders + playlists +/// + entries); the returned snapshot restores it verbatim (ids + entry order preserved) — the undo. +func checkFolderCascadeDeleteRestore(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/M/FD", + paths: ["/M/FD/a.flac", "/M/FD/b.flac"]) + let store = seeded.store + let t = seeded.trackIDs + let folder = try await store.createFolder(name: "F", parentID: nil) + let sub = try await store.createFolder(name: "Sub", parentID: folder) + let p1 = try await store.createPlaylist(name: "P1") + try await store.setPlaylistFolder(playlistID: p1, folderID: folder) + _ = try await store.appendEntry(playlistID: p1, trackID: t[0]) + let p2 = try await store.createPlaylist(name: "P2") + try await store.setPlaylistFolder(playlistID: p2, folderID: sub) + _ = try await store.appendEntries(playlistID: p2, trackIDs: t) + + let snapshot = try await store.deleteFolder(id: folder) + // Everything under `folder` is gone (cascade). + let foldersGone = try await store.folders().allSatisfy { $0.id != folder && $0.id != sub } + guard foldersGone, + try await store.playlist(id: p1) == nil, try await store.playlist(id: p2) == nil else { + printFail(number, "cascade delete left folder/playlist rows behind"); return false + } + guard snapshot.folders.count == 2, snapshot.playlists.count == 2, snapshot.entries.count == 3 else { + printFail(number, "snapshot wrong (folders=\(snapshot.folders.count) " + + "playlists=\(snapshot.playlists.count) entries=\(snapshot.entries.count))"); return false + } + // Undo: restore the subtree verbatim. + try await store.restoreFolderSubtree(snapshot) + let restored = try await store.folders() + guard restored.first(where: { $0.id == folder })?.parentID == nil, + restored.first(where: { $0.id == sub })?.parentID == folder, + try await store.playlist(id: p1) != nil, try await store.playlist(id: p2) != nil, + try await store.entries(inPlaylist: p1).map(\.trackID) == [t[0]], + try await store.entries(inPlaylist: p2).map(\.trackID) == t else { + printFail(number, "restore did not reinstate the subtree verbatim"); return false + } + printPass(number, "folder delete CASCADEs the subtree; the snapshot restores it verbatim (undo) — " + + "ids + entry order preserved") + return true + } catch { printFail(number, "pl-folder-cascade-delete-restore threw: \(error)"); return false } +} diff --git a/Sources/VerifyLibraryStore/main.swift b/Sources/VerifyLibraryStore/main.swift index 193170e..638fc77 100644 --- a/Sources/VerifyLibraryStore/main.swift +++ b/Sources/VerifyLibraryStore/main.swift @@ -179,7 +179,7 @@ func allCheckCases() -> [CheckCase] { ] + moveMatchCheckCases() + facetSweepCheckCases() + folderWatchCheckCases() + reachabilityCheckCases() + browseReadsCheckCases() + searchCheckCases() + songsSortCheckCases() + decodeCoverageCheckCases() + hardeningCheckCases() - + playlistSpineCheckCases() + + playlistSpineCheckCases() + playlistFolderCheckCases() } /// GRDB hardening (post-adoption follow-ups): single-pool DatabasePool concurrency + From 8277ab80eb9ce3e9a7d2fc8604cb3b01bf394aa2 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:27:36 +0530 Subject: [PATCH 08/15] feat(s10.3 B): PlaylistsModel + sidebar Playlists section + read-only detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk B (folds A2): the Playlists view-model over the same store as the rest of the library (one store, D-store rev.5) + a rebuilt Library sidebar with a dedicated Playlists section + a minimal read-only playlist detail. - PlaylistsModel: @MainActor @Observable peer (tree + open detail, per-surface epoch guards, built-in "current" filtered out). Verbs used here: loadTree / loadDetail / createPlaylist / renamePlaylist / deletePlaylist / reloadOnLibraryChange. The audio dep + play/remove/reorder verbs land in C, folder verbs in D — sliced so nothing sits as dead code under Periphery. - Sidebar rebuilt to ONE ScrollView { LazyVStack } of Button rows with a unified SidebarSelection (drops List(selection:) — its row .dropDestination never fires, needed for Chunk E's drag-to-playlist, and it races row gestures). Playlists section: create -> inline-rename, delete, keyboard up/down + Return. Music Folders footer preserved (moved to a same-file extension for length). - Read-only PlaylistDetailView reusing PlaylistItemRow (nil dragPayload); C wires play/remove/reorder onto the same rows, F renders the unavailable state. - Nav on LibraryBrowseModel (+Sidebar extension): sidebarSelection / selectCategory / selectPlaylist; LibraryRoute.playlist(Int64) rendered through the existing path.last -> LibraryRouteView seam. Reviews folded in: - swift-expert (Chunk A DAO): dedicated .wouldCreateCycle error; setPlaylistFolder throws .notFound/.builtinImmutable; restore-undo best-effort doc note. - swiftui-pro (Chunk B): delete-open-playlist nav redirect; click-away commits the rename (reverts only on conflict); deferred rename focus (no self-close); VoiceOver .isSelected traits; drill-down guard on up/down; loadDetail clears stale rows on switch. strict-gate PASSED; VerifyLibraryStore 117/117. --- Sources/AdaptiveSound/AdaptiveSound.swift | 8 + .../LibraryBrowseModel+Sidebar.swift | 33 ++ .../AdaptiveSound/LibraryBrowseModel.swift | 3 + Sources/AdaptiveSound/PlaylistsModel.swift | 202 +++++++++++ .../UI/Library/LibraryCategoryRoot.swift | 4 +- .../UI/Library/LibraryRoute.swift | 15 + .../UI/Library/LibrarySidebar.swift | 327 ++++++++++++++++-- .../UI/Library/LibraryTabView.swift | 4 + .../UI/Playlist/PlaylistDetailView.swift | 131 +++++++ 9 files changed, 702 insertions(+), 25 deletions(-) create mode 100644 Sources/AdaptiveSound/LibraryBrowseModel+Sidebar.swift create mode 100644 Sources/AdaptiveSound/PlaylistsModel.swift create mode 100644 Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift diff --git a/Sources/AdaptiveSound/AdaptiveSound.swift b/Sources/AdaptiveSound/AdaptiveSound.swift index 4ea7076..d61b45c 100644 --- a/Sources/AdaptiveSound/AdaptiveSound.swift +++ b/Sources/AdaptiveSound/AdaptiveSound.swift @@ -10,6 +10,10 @@ struct AdaptiveSound: App { @State private var eqViewModel: EQViewModel @State private var library: LibraryModel @State private var libraryModel: LibraryBrowseModel + /// S10.3: the Playlists view-model (sidebar tree + open detail), a peer over the SAME store as + /// `library` (one store — D-store rev.5). Owned here (above the tab switch) so its loaded tree + /// survives tab changes, like `libraryModel`. + @State private var playlistsModel: PlaylistsModel /// S10.4: macOS system control (Control Center / media keys / Now Playing widget). A peer, not a /// view — held in `@State` only for its lifetime; it reads the audio VM + calls its transport verbs. @State private var nowPlaying: NowPlayingController @@ -44,6 +48,9 @@ struct AdaptiveSound: App { // composes BOTH peers — library reads + audio play verbs. let browse = LibraryBrowseModel(audio: audio, library: lib) _libraryModel = State(initialValue: browse) + // S10.3: the Playlists model reads the same store `lib` owns (one store). Only `library` is + // wired now (tree + detail reads); the `audio` play verbs join in Chunk C with their UI. + _playlistsModel = State(initialValue: PlaylistsModel(library: lib)) // Edge 4 (S10.4): macOS system control. `NowPlayingController` reads `audio` + calls its // transport verbs from MPRemoteCommandCenter handlers, and pushes Now Playing on the VM's // `onNowPlayingRefresh` hook — same one-directional closure pattern as the edges above (no @@ -75,6 +82,7 @@ struct AdaptiveSound: App { .environment(eqViewModel) .environment(library) .environment(libraryModel) + .environment(playlistsModel) .environment(nowPlaying) // S10.4 D2: footer + widget read the resolved metadata .environment(keyboardFocus) .onAppear { diff --git a/Sources/AdaptiveSound/LibraryBrowseModel+Sidebar.swift b/Sources/AdaptiveSound/LibraryBrowseModel+Sidebar.swift new file mode 100644 index 0000000..bdce92f --- /dev/null +++ b/Sources/AdaptiveSound/LibraryBrowseModel+Sidebar.swift @@ -0,0 +1,33 @@ +import Foundation + +// MARK: - LibraryBrowseModel + sidebar selection (S10.3) + +/// Nav state for the rebuilt sidebar (design §1/§2 — nav lives on `LibraryBrowseModel`). Split into +/// this same-type extension for file length (like `+Facets` / `+History`). The sidebar is one +/// `ScrollView`/`LazyVStack` of Button rows with a unified `SidebarSelection`; these are the read +/// (`sidebarSelection`, for the capsule) and the writes (`selectCategory`/`selectPlaylist`). +@MainActor +extension LibraryBrowseModel { + /// The currently-highlighted sidebar row, derived from nav state: a playlist when the detail is + /// a `.playlist` route, else the selected category. Read by the sidebar to draw the selection + /// capsule; WRITTEN via `selectCategory`/`selectPlaylist` (Button taps), never bound directly. + var sidebarSelection: SidebarSelection { + if case let .playlist(id)? = path.last { return .playlist(id) } + return .category(selectedCategory ?? .songs) + } + + /// Select a browse category — clears any drill-down / open playlist so the category root shows. + /// Explicit `path` clear (not just `selectedCategory`'s didSet): re-selecting the ALREADY-current + /// category while a playlist is open leaves `selectedCategory` unchanged, so the didSet wouldn't + /// fire and the playlist would stay on screen. + func selectCategory(_ category: LibraryCategory) { + if !path.isEmpty { path.removeAll() } + selectedCategory = category + } + + /// Select a playlist — a TOP-LEVEL jump that replaces the browse stack (not a drill-down push), + /// so switching back to a category (which clears `path`) restores the category root cleanly. + func selectPlaylist(_ id: Int64) { + path = [.playlist(id)] + } +} diff --git a/Sources/AdaptiveSound/LibraryBrowseModel.swift b/Sources/AdaptiveSound/LibraryBrowseModel.swift index cfbc4f1..9832eb0 100644 --- a/Sources/AdaptiveSound/LibraryBrowseModel.swift +++ b/Sources/AdaptiveSound/LibraryBrowseModel.swift @@ -41,6 +41,9 @@ final class LibraryBrowseModel { var path: [LibraryRoute] = [] + // Sidebar selection (S10.3) lives in `LibraryBrowseModel+Sidebar` (a same-type extension, split + // for file length): `sidebarSelection` + `selectCategory`/`selectPlaylist`. + // Albums (S9.4). private(set) var albums: [AlbumFacet] = [] var albumSort: FacetSort = .title // S9.5 adds a "Recently Added" album sort (needs a DAO read) diff --git a/Sources/AdaptiveSound/PlaylistsModel.swift b/Sources/AdaptiveSound/PlaylistsModel.swift new file mode 100644 index 0000000..c3f4b54 --- /dev/null +++ b/Sources/AdaptiveSound/PlaylistsModel.swift @@ -0,0 +1,202 @@ +import Foundation +import LibraryStore +import SwiftUI + +// MARK: - PlaylistsModel (S10.3 — the playlists view-model) + +/// Owns the sidebar Playlists tree and the open-playlist detail, over the SAME store as the rest of +/// the library (`library.store` — the playlist DAO already lives there; D-store rev.5, one store). +/// `@MainActor` + `@Observable`, a composition-root peer of `LibraryBrowseModel` held as `@State` in +/// the `App` and injected via `.environment` — NOT `@State` in a view, because the tab area is a +/// `switch` that destroys the view on every tab change (would otherwise reset the loaded tree). +/// +/// Per-surface epoch tokens (tree / detail) mirror `LibraryBrowseModel`'s newest-wins discipline; +/// every mutation authoritatively re-reads. NAV (which playlist is selected) lives on +/// `LibraryBrowseModel` (`path`/`SidebarSelection`), not here — this model is the data + verbs. +/// +/// Scope note: this is the Chunk-B surface (flat list + create/rename/delete + read-only detail). +/// Play/remove/reorder verbs (+ the `audio` dependency) land in Chunk C; folders in Chunk D — added +/// alongside their UI consumers so nothing sits here as dead code under the hostile Periphery gate. +@MainActor +@Observable +final class PlaylistsModel { + /// Per-surface load state (drives the sidebar/detail spinner / empty / error rendering). + enum LoadState: Equatable { + case idle + case loading + case loaded + case empty + case failed(String) + } + + /// The library subsystem this reads through (store + `libraryRevision`). A peer dependency, kept + /// non-owning like `LibraryBrowseModel.library` (S3 F5 — the God-object split). + let library: LibraryModel + + // Sidebar tree (flat in B; folder rows join in D). + private(set) var playlists: [Playlist] = [] + private(set) var treeState: LoadState = .idle + /// Monotonic newest-wins token for `loadTree` (a slow read must not overwrite a newer one — + /// create/rename/delete + a scan-reconcile all trigger reloads concurrently). + private var treeEpoch = 0 + + // Open-playlist detail (rendered read-only by `PlaylistDetailView` in B; Chunk C wires + // play/remove/reorder onto the SAME loaded rows). + private(set) var openPlaylistID: Int64? + private(set) var detail: [PlaylistDetailEntry] = [] + private(set) var detailState: LoadState = .idle + private var detailEpoch = 0 + + /// The `library.libraryRevision` last folded in, so `reloadOnLibraryChange` refreshes exactly + /// once per library-content change (a track deletion CASCADE-drops entries → counts move). + private var lastLoadedRevision = 0 + + init(library: LibraryModel) { + self.library = library + } + + // MARK: - Store access + + /// The store once `LibraryModel` has finished building it (async at launch); nil early. + var store: LibraryStore? { + library.store + } + + /// Whether the async-built store is ready — drives the initial tree load so a visit BEFORE the + /// store finishes constructing isn't stuck on a spinner (same discipline as `LibraryBrowseModel`). + var isStoreReady: Bool { + library.store != nil + } + + // MARK: - Tree loading + + /// Load the user playlists (built-in "current" excluded — it's invisible + inert, D-store). Sets + /// `empty` when there are none, `failed` on error. Epoch-guarded; keeps the cached list on screen + /// while refreshing so a reload never flashes empty. + func loadTree() async { + guard let store else { + treeState = .loading // store still building; reloads on `isStoreReady` + return + } + treeEpoch &+= 1 + let epoch = treeEpoch + if playlists.isEmpty { treeState = .loading } + do { + // Built-in exclusion (is_builtin=0) — the "current" queue playlist is never a user row. + let loaded = try await store.playlists().filter { !$0.isBuiltin } + guard epoch == treeEpoch else { return } // a newer load superseded this one + playlists = loaded + treeState = loaded.isEmpty ? .empty : .loaded + } catch { + guard epoch == treeEpoch else { return } + treeState = .failed(error.localizedDescription) + } + } + + /// Refresh the tree + any open detail when a scan / metadata pass / reconcile changes library + /// content (a deleted track CASCADE-drops its entries → entry counts move). Coalesced to + /// `libraryRevision` — one reload per pass, mirroring `LibraryBrowseModel.reloadIfScanChanged`. + func reloadOnLibraryChange() async { + guard library.libraryRevision != lastLoadedRevision else { return } + lastLoadedRevision = library.libraryRevision + await loadTree() + if let id = openPlaylistID { await loadDetail(id: id) } + } + + // MARK: - Playlist lifecycle (create / rename / delete) + + /// Create a new "New Playlist"-style untitled playlist, reload, and return its id so the sidebar + /// can select it and drop straight into inline-rename. Nil if the store isn't ready or the + /// create failed (surfaced via `treeState`). + @discardableResult + func createPlaylist() async -> Int64? { + guard let store else { return nil } + do { + let id = try await store.createUntitledPlaylist() + await loadTree() + return id + } catch { + treeState = .failed(error.localizedDescription) + return nil + } + } + + /// Rename a playlist and reload. THROWS on a duplicate name (`PlaylistNameConflict`) or an + /// invalid/built-in target (`PlaylistMutationError`) so the inline-rename field can surface it + /// and keep editing — the caller decides the message; the model just re-reads on success. + func renamePlaylist(id: Int64, to name: String) async throws { + guard let store else { return } + try await store.renamePlaylist(id: id, to: name) + await loadTree() + } + + /// Delete a playlist and reload. Clears the open detail if it was the deleted one (so a stale + /// detail can't linger after its playlist is gone). Built-in deletion never reaches here (filtered). + func deletePlaylist(id: Int64) async { + guard let store else { return } + do { + try await store.deletePlaylist(id: id) + if openPlaylistID == id { closeDetail() } + await loadTree() + } catch { + treeState = .failed(error.localizedDescription) + } + } + + // MARK: - Detail loading + + /// Load a playlist's entries in position order, resolving each to its library track. An entry + /// whose track is unresolved carries a nil `display` (F renders the unavailable state; B just + /// shows the resolved rows). Epoch-guarded; the same loaded `detail` is what Chunk C acts on. + func loadDetail(id: Int64) async { + // Switching playlists: drop the previous rows so they can't linger under the NEW header/count + // while the read is in flight (the header reads `openPlaylist`/`detail.count` immediately). + if id != openPlaylistID { detail = [] } + openPlaylistID = id + detailEpoch &+= 1 + let epoch = detailEpoch + if detail.isEmpty { detailState = .loading } + guard let store else { + detailState = .loading + return + } + do { + let entries = try await store.entries(inPlaylist: id) + let byID = try await store.tracksDisplay(ids: entries.map(\.trackID)) + guard epoch == detailEpoch else { return } // a newer open/reload superseded this one + detail = entries.map { PlaylistDetailEntry(entry: $0, display: byID[$0.trackID]) } + detailState = detail.isEmpty ? .empty : .loaded + } catch { + guard epoch == detailEpoch else { return } + detailState = .failed(error.localizedDescription) + } + } + + /// Drop the open detail (playlist deselected / deleted). + func closeDetail() { + openPlaylistID = nil + detail = [] + detailState = .idle + } + + /// The open playlist's row (for the detail header title/count), or nil. + var openPlaylist: Playlist? { + guard let openPlaylistID else { return nil } + return playlists.first { $0.id == openPlaylistID } + } +} + +// MARK: - Detail row model + +/// One row of an open playlist: its `PlaylistEntry` (the stable id + position — the reorder/remove +/// key Chunk C needs) plus the resolved library track, or nil when the track can't be resolved +/// (moved/deleted — Chunk F renders the "unavailable" state + Locate). `Identifiable` on the ENTRY +/// id (not the track) so duplicates of the same track in one playlist stay distinct rows. +struct PlaylistDetailEntry: Identifiable { + let entry: PlaylistEntry + let display: LibraryTrackDisplay? + + var id: Int64 { + entry.id + } +} diff --git a/Sources/AdaptiveSound/UI/Library/LibraryCategoryRoot.swift b/Sources/AdaptiveSound/UI/Library/LibraryCategoryRoot.swift index 26fea6f..eba59fd 100644 --- a/Sources/AdaptiveSound/UI/Library/LibraryCategoryRoot.swift +++ b/Sources/AdaptiveSound/UI/Library/LibraryCategoryRoot.swift @@ -22,7 +22,7 @@ struct LibraryCategoryRoot: View { } } -/// The destination for a pushed `LibraryRoute` (album / artist / genre detail). +/// The destination for a pushed `LibraryRoute` (album / artist / genre detail, or a selected playlist). struct LibraryRouteView: View { let route: LibraryRoute @@ -34,6 +34,8 @@ struct LibraryRouteView: View { ArtistDetailView(artistID: id) case let .genre(id): GenreDetailView(genreID: id) + case let .playlist(id): + PlaylistDetailView(playlistID: id) } } } diff --git a/Sources/AdaptiveSound/UI/Library/LibraryRoute.swift b/Sources/AdaptiveSound/UI/Library/LibraryRoute.swift index 16017e8..07e7ed5 100644 --- a/Sources/AdaptiveSound/UI/Library/LibraryRoute.swift +++ b/Sources/AdaptiveSound/UI/Library/LibraryRoute.swift @@ -12,4 +12,19 @@ enum LibraryRoute: Hashable { case album(Int64) case artist(Int64) case genre(Int64) + /// A selected playlist's detail (S10.3). Reached from the sidebar Playlists section (a top-level + /// jump, so it REPLACES `path` rather than pushing — see `LibraryBrowseModel.selectPlaylist`), + /// and rendered through the same `path.last → LibraryRouteView` seam as the browse drill-downs. + case playlist(Int64) +} + +// MARK: - Sidebar selection (S10.3 — unified category + playlist selection) + +/// The single selection model for the rebuilt sidebar (one `ScrollView`/`LazyVStack` of Button rows, +/// not `List(selection:)` whose drops don't fire and which races row gestures — design §1). A row is +/// highlighted when it equals `LibraryBrowseModel.sidebarSelection`. Folders (D) will add nodes to +/// the ordered walk, not new cases here (a folder is a container, not a content selection). +enum SidebarSelection: Hashable { + case category(LibraryCategory) + case playlist(Int64) } diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index f822949..b1cd038 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -1,42 +1,326 @@ +import LibraryStore import SwiftUI import UniformTypeIdentifiers -// MARK: - Library sidebar (S9.4 + S9 IA change: Music Folders footer) +// MARK: - Library sidebar (S9.4 + S9 IA Music Folders footer + S10.3 Playlists section) -/// The category list plus a pinned footer for library-source management. Category selection -/// binds to the injected model (`selectedCategory`), so it survives tab teardown; `.tag(category)` -/// makes the `List` selection a `LibraryCategory?`. The footer (a `.safeAreaInset`, so it never -/// scrolls with the categories and survives category switches) hosts the ambient scan-status -/// strip and the "Music Folders" management entry — the canonical macOS home for library sources. +/// The browse categories PLUS a dedicated Playlists section, over a pinned Music Folders footer. +/// +/// ★ S10.3 rebuild (design §1): the whole list is ONE `ScrollView { LazyVStack }` of plain `Button` +/// rows with a single `SidebarSelection` — NOT `List(selection:)`. A `List` row's `.dropDestination` +/// never fires (needed for drag-to-playlist in Chunk E) and `List(selection:)` races custom row +/// gestures + double-highlights against a second selection system. Selection lives on the injected +/// `LibraryBrowseModel` (survives the tab-switch teardown); the capsule is `Color.rowSelected`. ↑/↓ +/// walk the unified row order via `.onKeyPress` + `@FocusState` (the `List` freebie, re-created). struct LibrarySidebar: View { @Environment(LibraryBrowseModel.self) private var model + @Environment(PlaylistsModel.self) private var playlists + /// Suppresses the global Space accelerator while the inline-rename field is focused (S4 SW1). + @Environment(KeyboardTransportFocus.self) private var keyboardFocus @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var showFolderImporter = false - /// Music Folders accordion expand/collapse — persisted across launches and across this view - /// being recreated mid-session (design §8), matching the `.v1`-key-versioned `@AppStorage` - /// convention `EQTabView` already uses for its interpolation-mode toggle. + // Inline-rename state (design §4: editing id in parent @State). `editDraft` is the field text; + // `renameError` shows an inline conflict message and keeps the field open. + @State private var editingPlaylistID: Int64? + @State private var editDraft = "" + @State private var renameError: String? + @FocusState private var renameFieldFocused: Bool + + /// Keyboard-command focus for the scroll area (a ScrollView/LazyVStack doesn't own key focus the + /// way a `List` does — same `.focusable`/`.focused`/`.defaultFocus` pattern the queue uses). + @FocusState private var sidebarFocused: Bool + + /// Music Folders accordion expand/collapse — persisted across launches and view recreation + /// (design §8), matching the `.v1`-key `@AppStorage` convention `EQTabView` uses. @AppStorage("library.foldersExpanded.v1") private var isFoldersExpanded = false + /// The unified top-to-bottom row order for ↑/↓ navigation (categories, then playlists). Chunk D + /// inserts folder nodes here; the enum gains no cases (a folder is a container, not a selection). + private var selectables: [SidebarSelection] { + LibraryCategory.allCases.map(SidebarSelection.category) + + playlists.playlists.map { SidebarSelection.playlist($0.id) } + } + var body: some View { - @Bindable var model = model - List(selection: $model.selectedCategory) { - ForEach(LibraryCategory.allCases) { category in - Label(category.title, systemImage: category.icon) - .tag(category) + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(LibraryCategory.allCases) { category in + categoryRow(category) + } + playlistsSectionHeader + playlistRows + } + .padding(.horizontal, DesignSystem.Spacing.small) + .padding(.vertical, DesignSystem.Spacing.xSmall) } + .focusable() + .focused($sidebarFocused) + .defaultFocus($sidebarFocused, true) + .focusEffectDisabled() + .onKeyPress(.upArrow) { moveSelection(by: -1) } + .onKeyPress(.downArrow) { moveSelection(by: 1) } + // Return renames the selected playlist (Finder/Music convention + keyboard discoverability + // for an action otherwise only in the right-click menu). Categories ignore it (bubbles). + .onKeyPress(.return) { renameSelectedPlaylist() } } - // Source-list appearance — set explicitly now that the enclosing NavigationSplitView is - // gone (the split view used to imply it). Standalone `.sidebar` is just a list style; it - // does NOT recreate the split view's under-the-titlebar coordination. - .listStyle(.sidebar) + // A sidebar material so the column reads as a source list now that `.listStyle(.sidebar)` is + // gone (the plain ScrollView doesn't imply it). + .background(.bar) .safeAreaInset(edge: .bottom) { footer } .fileImporter(isPresented: $showFolderImporter, allowedContentTypes: [.folder]) { result in if case let .success(url) = result { model.addFolder(url) } } + .task { await playlists.loadTree() } + // Load the tree once the async store finishes building (a visit before then shows nothing). + .onChange(of: playlists.isStoreReady) { _, ready in + if ready { Task { await playlists.loadTree() } } + } + } + + // MARK: - Category rows + + private func categoryRow(_ category: LibraryCategory) -> some View { + let isSelected = model.sidebarSelection == .category(category) + return Button { + model.selectCategory(category) + sidebarFocused = true + } label: { + rowLabel(isSelected: isSelected) { + Label(category.title, systemImage: category.icon) + } + } + .buttonStyle(.plain) + } + + // MARK: - Playlists section + + private var playlistsSectionHeader: some View { + HStack(spacing: DesignSystem.Spacing.small) { + Text("Playlists") + .font(DesignSystem.Font.micro) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(DesignSystem.Color.labelSecondary) + Spacer(minLength: 0) + Button { + Task { await createAndBeginRename() } + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .foregroundStyle(DesignSystem.Color.accent) + .disabled(!playlists.isStoreReady) + .help("New Playlist") + .accessibilityLabel("New Playlist") + } + .padding(.horizontal, DesignSystem.Spacing.small) + .padding(.top, DesignSystem.Spacing.medium) + .padding(.bottom, DesignSystem.Spacing.xSmall) + } + + @ViewBuilder private var playlistRows: some View { + if playlists.playlists.isEmpty { + Text("No playlists yet") + .font(DesignSystem.Font.caption) + .foregroundStyle(DesignSystem.Color.labelTertiary) + .padding(.horizontal, DesignSystem.Spacing.small) + .padding(.vertical, DesignSystem.Spacing.xSmall) + } else { + ForEach(playlists.playlists) { playlist in + playlistRow(playlist) + } + } + } + + @ViewBuilder + private func playlistRow(_ playlist: Playlist) -> some View { + if editingPlaylistID == playlist.id { + renameField(playlist) + } else { + let isSelected = model.sidebarSelection == .playlist(playlist.id) + Button { + model.selectPlaylist(playlist.id) + sidebarFocused = true + } label: { + rowLabel(isSelected: isSelected) { + HStack(spacing: DesignSystem.Spacing.small) { + Image(systemName: "music.note.list") + Text(playlist.name).lineLimit(1) + Spacer(minLength: DesignSystem.Spacing.small) + Text(playlist.entryCount.formatted(.number)) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + } + } + } + .buttonStyle(.plain) + .contextMenu { + Button("Rename") { beginRename(playlist) } + Button("Delete", role: .destructive) { deletePlaylist(playlist) } + } + } } - private var footer: some View { + private func renameField(_ playlist: Playlist) -> some View { + VStack(alignment: .leading, spacing: 2) { + TextField("Playlist name", text: $editDraft) + .textFieldStyle(.plain) + .font(DesignSystem.Font.body) + .focused($renameFieldFocused) + // Suppress the global Space accelerator while editing (S4 SW1) — the same inline + // trio `LibraryFilterField`/`SongsHeader` use; Chunk E extracts it to a modifier. + // Click-away COMMITS (Finder/Music convention — silent revert is surprising data + // loss). Guarded on `wasFocused` so the deferred-focus arrival (false→true) can't + // self-commit, and on `editingPlaylistID` so a post-teardown blur is a no-op. + // Escape (`.onExitCommand`) is the sole cancel and niles `editingPlaylistID` first, + // so a blur it triggers is guarded out (no commit-on-Escape). + .onChange(of: renameFieldFocused) { wasFocused, isFocused in + keyboardFocus.isTextEntryFocused = isFocused + if wasFocused, !isFocused, editingPlaylistID == playlist.id { + commitRename(playlist, proposed: editDraft, keepOpenOnConflict: false) + } + } + .onDisappear { keyboardFocus.isTextEntryFocused = false } + // Capture the draft SYNCHRONOUSLY at submit: a later blur/teardown that clears + // `editDraft` must not race the async rename into an empty/stale name. + .onSubmit { commitRename(playlist, proposed: editDraft, keepOpenOnConflict: true) } + .onExitCommand { cancelRename() } + .padding(.horizontal, DesignSystem.Spacing.small) + .padding(.vertical, 5) + if let renameError { + Text(renameError) + .font(DesignSystem.Font.caption) + .foregroundStyle(.red) + .padding(.horizontal, DesignSystem.Spacing.small) + } + } + } + + // MARK: - Row chrome + + /// The shared row capsule: selection tint + accent-on-selected label color, consistent leading + /// inset. Content is a `Label`/`HStack` supplied by the caller. + private func rowLabel(isSelected: Bool, @ViewBuilder content: () -> some View) -> some View { + content() + .font(DesignSystem.Font.body) + .foregroundStyle(isSelected ? DesignSystem.Color.accent : DesignSystem.Color.label) + .padding(.horizontal, DesignSystem.Spacing.small) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? DesignSystem.Color.rowSelected : Color.clear, + in: RoundedRectangle(cornerRadius: DesignSystem.Radius.control) + ) + .contentShape(Rectangle()) + // Selection is conveyed by color alone otherwise — expose it to VoiceOver (matching + // `PlaylistItemRow`); `.combine` folds a trailing count into the one row element. + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + // MARK: - Actions + + /// Create a new untitled playlist, select it, and drop straight into inline-rename (Apple-style). + private func createAndBeginRename() async { + guard let id = await playlists.createPlaylist() else { return } + model.selectPlaylist(id) + if let created = playlists.playlists.first(where: { $0.id == id }) { beginRename(created) } + } + + private func beginRename(_ playlist: Playlist) { + editDraft = playlist.name + renameError = nil + editingPlaylistID = playlist.id + // Defer the focus set: the `TextField` only enters the hierarchy BECAUSE `editingPlaylistID` + // just changed, and focusing a not-yet-inserted field can bounce `@FocusState` back to false + // → the blur handler self-closes the rename. Setting it next runloop targets a live field. + Task { renameFieldFocused = true } + } + + /// Begin renaming the selected playlist (keyboard Return). `.ignored` unless a playlist row is + /// the current selection, so the event bubbles for categories / drill-downs. + private func renameSelectedPlaylist() -> KeyPress.Result { + guard case let .playlist(id) = model.sidebarSelection, + let playlist = playlists.playlists.first(where: { $0.id == id }) else { return .ignored } + beginRename(playlist) + return .handled + } + + /// Delete a playlist; if it was the open/selected one, redirect nav back to the current category + /// so the detail pane doesn't orphan on a `.playlist(deletedID)` route that resolves to nothing. + private func deletePlaylist(_ playlist: Playlist) { + let wasSelected = model.sidebarSelection == .playlist(playlist.id) + Task { + await playlists.deletePlaylist(id: playlist.id) + if wasSelected { model.selectCategory(model.selectedCategory ?? .songs) } + } + } + + /// Commit the rename from a draft captured synchronously at submit time (`proposed`). Empty or + /// unchanged → cancel (no write). On a duplicate name (D-names: globally unique): when committing + /// via Return (`keepOpenOnConflict`) the field stays open with an inline message; on click-away + /// it reverts silently (don't trap a user who's leaving). Guarded so a stale/torn-down field + /// can't commit. + private func commitRename(_ playlist: Playlist, proposed: String, keepOpenOnConflict: Bool) { + guard editingPlaylistID == playlist.id else { return } + let name = proposed.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name != playlist.name else { cancelRename(); return } + Task { + do { + try await playlists.renamePlaylist(id: playlist.id, to: name) + cancelRename() + } catch let conflict as PlaylistNameConflict where keepOpenOnConflict { + renameError = "“\(conflict.name)” already exists." + renameFieldFocused = true + } catch { + if keepOpenOnConflict { + renameError = "Couldn’t rename this playlist." + renameFieldFocused = true + } else { + cancelRename() // leaving the field: revert rather than trap on the error + } + } + } + } + + private func cancelRename() { + editingPlaylistID = nil + editDraft = "" + renameError = nil + keyboardFocus.isTextEntryFocused = false + } + + /// Move the unified selection by `delta` rows through `selectables` (keyboard ↑/↓). `.ignored` + /// when the move would leave the list, so the event can bubble. Also `.ignored` while a browse + /// drill-down (album/artist/genre detail) is showing: `sidebarSelection` collapses that to its + /// category, so an arrow press would otherwise navigate away and DESTROY the drill-down. + private func moveSelection(by delta: Int) -> KeyPress.Result { + if let route = model.path.last { + switch route { + case .album, .artist, .genre: return .ignored // a drill-down is open — don't blow it away + case .playlist: break // a playlist is selected — arrow nav among rows is fine + } + } + let items = selectables + guard let current = items.firstIndex(of: model.sidebarSelection) else { return .ignored } + let next = current + delta + guard next >= 0, next < items.count else { return .ignored } + switch items[next] { + case let .category(category): model.selectCategory(category) + case let .playlist(id): model.selectPlaylist(id) + } + return .handled + } +} + +// MARK: - Music Folders footer (S9 IA change — unchanged) + +/// Split into a same-type extension to keep the primary `LibrarySidebar` body under the +/// type-body-length limit (same pattern as `LibraryBrowseModel+Facets`). +private extension LibrarySidebar { + var footer: some View { VStack(spacing: 0) { if let status = model.scanStatusText { HStack(spacing: DesignSystem.Spacing.small) { @@ -59,9 +343,6 @@ struct LibrarySidebar: View { isFoldersExpanded.toggle() } } label: { - // A shared `.font` on the HStack (not just the `Text`) so the chevron scales - // in step with the label at larger Dynamic Type sizes (design §10) instead of - // staying a fixed-size glyph next to growing text. HStack(spacing: DesignSystem.Spacing.small) { Image(systemName: "chevron.forward") .rotationEffect(.degrees(isFoldersExpanded ? 90 : 0)) @@ -94,8 +375,6 @@ struct LibrarySidebar: View { .transition(.opacity.combined(with: .move(edge: .top))) } } - // A material (not an opaque fill) so the translucent sidebar shows through; the hairline - // above is the separator (review S5). .background(.bar) } } diff --git a/Sources/AdaptiveSound/UI/Library/LibraryTabView.swift b/Sources/AdaptiveSound/UI/Library/LibraryTabView.swift index bc575e6..65f05d7 100644 --- a/Sources/AdaptiveSound/UI/Library/LibraryTabView.swift +++ b/Sources/AdaptiveSound/UI/Library/LibraryTabView.swift @@ -19,6 +19,7 @@ import SwiftUI struct LibraryTabView: View { @Environment(LibraryBrowseModel.self) private var model @Environment(LibraryModel.self) private var library + @Environment(PlaylistsModel.self) private var playlists var body: some View { HStack(spacing: 0) { @@ -41,6 +42,9 @@ struct LibraryTabView: View { // per metadata tick, and not the earlier `lastScanResult` (design §7; review B1). .onChange(of: library.libraryRevision) { _, _ in Task { await model.reloadIfScanChanged() } + // Playlist entry counts move when a track deletion CASCADE-drops entries (S10.3) — keep + // the sidebar counts + any open detail truthful on the same coalesced revision bump. + Task { await playlists.reloadOnLibraryChange() } } } diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift new file mode 100644 index 0000000..1cad2ba --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift @@ -0,0 +1,131 @@ +import LibraryStore +import SwiftUI + +// MARK: - Playlist detail (S10.3 — the open-playlist content pane) + +/// The detail shown when a playlist is selected in the sidebar. Loads through `PlaylistsModel` +/// (entries in position order, each resolved to its library track) and renders the rows READ-ONLY +/// by reusing `PlaylistItemRow` in its no-drag form. Chunk C wires play / remove / reorder onto +/// these SAME rows (tap-to-play, the header play verbs + restore-queue undo, grip-drag reorder); +/// Chunk F renders the unavailable state for an entry whose file moved/was deleted. +struct PlaylistDetailView: View { + let playlistID: Int64 + @Environment(PlaylistsModel.self) private var model + + /// Track-number column sized to the widest index, so a 3-digit position never wraps (queue idiom). + private var numberColumnWidth: CGFloat { + let digits = max(2, String(model.detail.count).count) + return CGFloat(digits) * 8 + 6 + } + + var body: some View { + VStack(spacing: 0) { + header + Rectangle().fill(DesignSystem.Color.hairline).frame(height: 0.5) + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(DesignSystem.Color.window) + // Reload whenever the selected playlist changes (a new sidebar selection reuses this view). + .task(id: playlistID) { await model.loadDetail(id: playlistID) } + } + + // MARK: Header + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Playlist") + .font(DesignSystem.Font.micro) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(DesignSystem.Color.labelSecondary) + Text(model.openPlaylist?.name ?? "Playlist") + .font(DesignSystem.Font.sectionTitle) + .foregroundStyle(DesignSystem.Color.label) + .lineLimit(1) + } + Spacer() + Text(countLine) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + } + .padding(.horizontal, DesignSystem.LayoutMetrics.screenInsetH) + .padding(.vertical, DesignSystem.Spacing.medium) + } + + private var countLine: String { + let count = model.detail.count + return "\(count.formatted(.number)) \(count == 1 ? "track" : "tracks")" + } + + // MARK: Content + + @ViewBuilder private var content: some View { + switch model.detailState { + case .idle, .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(message): + ContentUnavailableView { + Label("Couldn’t Load Playlist", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .empty: + ContentUnavailableView { + Label("No Tracks Yet", systemImage: "music.note.list") + } description: { + Text("Add songs from your Library to build this playlist.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded: + trackList + } + } + + private var trackList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.detail.enumerated()), id: \.element.id) { index, row in + detailRow(index: index, row: row) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func detailRow(index: Int, row: PlaylistDetailEntry) -> some View { + if let display = row.display { + // Read-only reuse: nil `dragPayload` → no grip; no tap gesture (Chunk C adds play/reorder). + PlaylistItemRow( + file: AudioFile(display), + index: index, + isSelected: false, + isNowPlaying: false, + numberColumnWidth: numberColumnWidth + ) + } else { + // Placeholder for an unresolved track (moved/deleted). Chunk F replaces this with a + // proper "unavailable" badge + Locate / Remove-missing affordances. + unavailableRow(index: index) + } + } + + private func unavailableRow(index: Int) -> some View { + HStack(spacing: 12) { + Text(index + 1, format: .number.grouping(.never)) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + .frame(width: numberColumnWidth, alignment: .trailing) + Text("Track unavailable") + .font(DesignSystem.Font.body) + .foregroundStyle(DesignSystem.Color.labelTertiary) + Spacer() + } + .padding(.vertical, DesignSystem.Spacing.xSmall) + .padding(.horizontal, DesignSystem.Spacing.small) + .frame(maxWidth: .infinity, alignment: .leading) + } +} From cdca1be16bd7697ed4eb9dd43de309e554ed38d1 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:11:08 +0530 Subject: [PATCH 09/15] fix(s10.3): A+B break-it review batch (qa-expert + architect/Fool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every finding from the qa-expert + architect-reviewer/Fool break-it on chunks A+B, plus the three founder decisions. Both reviewers steelmanned the one-store reversal + the sidebar rebuild as correct; these harden the edges. Migrator posture (architect BLOCKER — the additive-preserve check only proved GRDB, not our migrator): - strict-gate grep forbids `eraseDatabaseOnSchemaChange = true` / asserts `= false` (Attack A: silent re-enable of drop-and-recreate). - new `additive-migration-convergence` check: staged-upgrade == fresh-install + a golden FNV-1a schema-fingerprint hash that trips on any EDITED shipped migration body (Attack B: fresh installs diverge from migrated users). - `makeMigrator` invariant reworded: data-preserving, APPEND-NEVER-EDIT (+ the 12-step table-rebuild escape hatch for destructive changes). Corruption/quarantine (founder: signal now, backup fast-follow): - LibraryStore.quarantinedFrom surfaces a rebuild-recoverable wipe to the shell error banner instead of silently losing playlists + play history. Names (founder: case-insensitive): - v6 migration recreates idx_playlists_name_user COLLATE NOCASE + NOCASE conflict probe ("Rock" == "rock"), matching the reserved-name guard + the sort. Folder DAO: - reparentFolder re-positions to end-of-new-group (drag-drop "append"). - new checks: pl-folder-restore-conflict-rolls-back, pl-set-playlist-folder-rejects, pl-folder-edge-cases (empty / reparent-nil / 40-level). VerifyLibraryStore 121/121. App layer: - closeDetail bumps detailEpoch (no in-flight republish of a deleted playlist). - delete redirects nav only on confirmed success. - rename: re-checks the row after the await + a reserved-name message. - built-in exclusion extracted to the pure, unit-tested PlaylistBrowseVisibility (3/3). Docs: design §8 (break-it outcomes + carry-forward deferrals for C/D/E/F); sprint-plan §Status (S10.4 shipped, S10.3 A+B landed). strict-gate PASSED. --- Sources/AdaptiveSound/LibraryModel+Scan.swift | 8 ++ Sources/AdaptiveSound/PlaylistsModel.swift | 23 +++- .../UI/Library/LibrarySidebar.swift | 12 +- .../PlaylistBrowseVisibility.swift | 19 +++ .../LibraryStore+PlaylistFolders.swift | 13 +- .../LibraryStore/LibraryStore+Playlists.swift | 4 +- Sources/LibraryStore/LibraryStore.swift | 48 ++++++-- Sources/LibraryStore/Schema.swift | 26 +++- Sources/VerifyLibraryStore/Checks.swift | 3 + .../ChecksMigrationConvergence.swift | 99 +++++++++++++++ .../ChecksPlaylistFolders.swift | 113 ++++++++++++++++++ Sources/VerifyLibraryStore/main.swift | 1 + .../PlaylistBrowseVisibilityTests.swift | 38 ++++++ docs/sprints/s10-3-playlists-ux-design.md | 19 ++- docs/sprints/sprint-plan.md | 2 +- scripts/strict-gate.sh | 19 +++ 16 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 Sources/LibraryBrowseKit/PlaylistBrowseVisibility.swift create mode 100644 Sources/VerifyLibraryStore/ChecksMigrationConvergence.swift create mode 100644 Tests/LibraryBrowseKitTests/PlaylistBrowseVisibilityTests.swift diff --git a/Sources/AdaptiveSound/LibraryModel+Scan.swift b/Sources/AdaptiveSound/LibraryModel+Scan.swift index 0bdac49..43de6ff 100644 --- a/Sources/AdaptiveSound/LibraryModel+Scan.swift +++ b/Sources/AdaptiveSound/LibraryModel+Scan.swift @@ -26,6 +26,14 @@ extension LibraryModel { let url = try LibraryStore.defaultStoreURL() let created = try await LibraryStore(url: url, appBuild: appBuildIdentifier) store = created + // A quarantine-rebuild means the on-disk DB was damaged and reset — and it held + // user data (playlists + play history) a rebuild can't recover. Surface it (don't wipe + // in silence, S10.3 break-it); the derived library re-populates on the next scan. + if let quarantined = created.quarantinedFrom { + onError?("Your library database was damaged and was reset. Playlists and play " + + "history couldn't be recovered. The previous file was saved to " + + "\(quarantined.path).") + } if let cacheURL = try? LibraryStore.defaultArtworkCacheURL() { metadataArtworkCache = ArtworkCache(directory: cacheURL) } diff --git a/Sources/AdaptiveSound/PlaylistsModel.swift b/Sources/AdaptiveSound/PlaylistsModel.swift index c3f4b54..a654bd0 100644 --- a/Sources/AdaptiveSound/PlaylistsModel.swift +++ b/Sources/AdaptiveSound/PlaylistsModel.swift @@ -1,4 +1,5 @@ import Foundation +import LibraryBrowseKit import LibraryStore import SwiftUI @@ -82,8 +83,10 @@ final class PlaylistsModel { let epoch = treeEpoch if playlists.isEmpty { treeState = .loading } do { - // Built-in exclusion (is_builtin=0) — the "current" queue playlist is never a user row. - let loaded = try await store.playlists().filter { !$0.isBuiltin } + // Built-in exclusion (the "current" queue playlist never appears) via the pure, + // unit-tested `PlaylistBrowseVisibility` — not an inline filter (design §5). + let all = try await store.playlists() + let loaded = PlaylistBrowseVisibility.userVisible(all) guard epoch == treeEpoch else { return } // a newer load superseded this one playlists = loaded treeState = loaded.isEmpty ? .empty : .loaded @@ -131,15 +134,20 @@ final class PlaylistsModel { } /// Delete a playlist and reload. Clears the open detail if it was the deleted one (so a stale - /// detail can't linger after its playlist is gone). Built-in deletion never reaches here (filtered). - func deletePlaylist(id: Int64) async { - guard let store else { return } + /// detail can't linger after its playlist is gone). Built-in deletion never reaches here + /// (filtered). RETURNS whether the delete succeeded, so the caller only redirects nav on success + /// (a failed delete leaves the row + surfaces via `treeState`, and nav must not jump away). + @discardableResult + func deletePlaylist(id: Int64) async -> Bool { + guard let store else { return false } do { try await store.deletePlaylist(id: id) if openPlaylistID == id { closeDetail() } await loadTree() + return true } catch { treeState = .failed(error.localizedDescription) + return false } } @@ -172,8 +180,11 @@ final class PlaylistsModel { } } - /// Drop the open detail (playlist deselected / deleted). + /// Drop the open detail (playlist deselected / deleted). Bumps `detailEpoch` so an IN-FLIGHT + /// `loadDetail` (whose store read may have snapshotted the rows before the delete committed) + /// can't resume and republish the gone playlist's rows into `detail` (QA break-it #1). func closeDetail() { + detailEpoch &+= 1 openPlaylistID = nil detail = [] detailState = .idle diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index b1cd038..592a002 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -250,11 +250,12 @@ struct LibrarySidebar: View { /// Delete a playlist; if it was the open/selected one, redirect nav back to the current category /// so the detail pane doesn't orphan on a `.playlist(deletedID)` route that resolves to nothing. + /// Only redirects on a CONFIRMED delete — a failed delete leaves the row, so nav must stay put. private func deletePlaylist(_ playlist: Playlist) { let wasSelected = model.sidebarSelection == .playlist(playlist.id) Task { - await playlists.deletePlaylist(id: playlist.id) - if wasSelected { model.selectCategory(model.selectedCategory ?? .songs) } + let deleted = await playlists.deletePlaylist(id: playlist.id) + if deleted, wasSelected { model.selectCategory(model.selectedCategory ?? .songs) } } } @@ -270,10 +271,15 @@ struct LibrarySidebar: View { Task { do { try await playlists.renamePlaylist(id: playlist.id, to: name) - cancelRename() + // The user may have begun renaming ANOTHER row during the await — only close if THIS + // playlist is still the one being edited (QA break-it #5). + if editingPlaylistID == playlist.id { cancelRename() } } catch let conflict as PlaylistNameConflict where keepOpenOnConflict { renameError = "“\(conflict.name)” already exists." renameFieldFocused = true + } catch PlaylistMutationError.invalidName where keepOpenOnConflict { + renameError = "That name can’t be used." // reserved ("current") — empty is pre-guarded + renameFieldFocused = true } catch { if keepOpenOnConflict { renameError = "Couldn’t rename this playlist." diff --git a/Sources/LibraryBrowseKit/PlaylistBrowseVisibility.swift b/Sources/LibraryBrowseKit/PlaylistBrowseVisibility.swift new file mode 100644 index 0000000..cc53931 --- /dev/null +++ b/Sources/LibraryBrowseKit/PlaylistBrowseVisibility.swift @@ -0,0 +1,19 @@ +import LibraryStore + +// MARK: - PlaylistBrowseVisibility (S10.3 — built-in exclusion, pure + testable) + +/// Decides which playlists appear in the browse UI. The built-in "current" queue playlist +/// (`is_builtin = 1`) is invisible + inert everywhere (D-store) — only user playlists show in the +/// sidebar. Extracted as a pure function (design §5) so the "built-in never leaks" invariant is +/// unit-testable, rather than an inline `!$0.isBuiltin` filter that no test can pin. +public enum PlaylistBrowseVisibility { + /// Whether a single playlist is user-visible (i.e. NOT the built-in). + public static func isUserVisible(_ playlist: Playlist) -> Bool { + !playlist.isBuiltin + } + + /// The user-visible subset, order preserved (the built-in is dropped wherever it sits). + public static func userVisible(_ playlists: [Playlist]) -> [Playlist] { + playlists.filter(isUserVisible) + } +} diff --git a/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift index 7d02fcb..8bd20a1 100644 --- a/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift +++ b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift @@ -70,7 +70,11 @@ public extension LibraryStore { private static let maxFolderPositionSQL = "SELECT COALESCE(MAX(position), -1) FROM playlist_folders WHERE parent_id IS ?;" private static let renameFolderSQL = "UPDATE playlist_folders SET name = ? WHERE id = ?;" - private static let reparentFolderSQL = "UPDATE playlist_folders SET parent_id = ? WHERE id = ?;" + /// A reparent sets BOTH parent and position: a moved folder is appended to the END of its NEW + /// sibling group (its old position is meaningless there — a drag-drop expects "append"). + private static let reparentFolderSQL = + "UPDATE playlist_folders SET parent_id = ?, position = ? WHERE id = ?;" + private static let selectFolderParentSQL = "SELECT parent_id FROM playlist_folders WHERE id = ?;" private static let deleteFolderSQL = "DELETE FROM playlist_folders WHERE id = ?;" private static let folderExistsSQL = "SELECT 1 FROM playlist_folders WHERE id = ?;" /// Distinguishes an absent playlist (→ `.notFound`) from the built-in (→ `.builtinImmutable`) @@ -163,7 +167,12 @@ public extension LibraryStore { throw PlaylistMutationError.wouldCreateCycle(id: id, newParentID: newParentID) } } - try db.execute(sql: Self.reparentFolderSQL, arguments: [newParentID, id]) + // Re-position ONLY when the parent actually changes (a same-parent "reparent" is a no-op + // and must not jump the folder to the end of its own group). `parent_id` NULL = root. + let currentParent = try Int64.fetchOne(db, sql: Self.selectFolderParentSQL, arguments: [id]) + guard currentParent != newParentID else { return } + let maxPos = try Int64.fetchOne(db, sql: Self.maxFolderPositionSQL, arguments: [newParentID]) ?? -1 + try db.execute(sql: Self.reparentFolderSQL, arguments: [newParentID, maxPos + 1, id]) } } diff --git a/Sources/LibraryStore/LibraryStore+Playlists.swift b/Sources/LibraryStore/LibraryStore+Playlists.swift index 9849034..0eb3c2f 100644 --- a/Sources/LibraryStore/LibraryStore+Playlists.swift +++ b/Sources/LibraryStore/LibraryStore+Playlists.swift @@ -121,8 +121,10 @@ public extension LibraryStore { private static let selectEntriesSQL = "SELECT id, playlist_id, track_id, position, added_at FROM playlist_entries " + "WHERE playlist_id = ? ORDER BY position ASC, id ASC;" + /// Case-INSENSITIVE conflict probe (v6): matches the NOCASE unique index + the reserved-name + /// guard, so "Rock" and "rock" collide (D-names: globally unique, case-insensitive). private static let selectUserNameConflictSQL = - "SELECT id FROM playlists WHERE name = ? AND is_builtin = 0 LIMIT 1;" + "SELECT id FROM playlists WHERE name = ? COLLATE NOCASE AND is_builtin = 0 LIMIT 1;" private static let selectIsBuiltinSQL = "SELECT is_builtin FROM playlists WHERE id = ?;" private static let selectBuiltinIDSQL = "SELECT id FROM playlists WHERE is_builtin = 1 LIMIT 1;" private static let selectUntitledNamesSQL = diff --git a/Sources/LibraryStore/LibraryStore.swift b/Sources/LibraryStore/LibraryStore.swift index e7a844d..284b772 100644 --- a/Sources/LibraryStore/LibraryStore.swift +++ b/Sources/LibraryStore/LibraryStore.swift @@ -38,6 +38,11 @@ public final class LibraryStore: Sendable { /// The schema version reached after migrate (read back from `schema_info`). private let version: Int + /// The quarantined path when a PRE-EXISTING file was corrupt/unusable and this store was rebuilt + /// fresh (nil on a normal open). The rebuild loses NON-rebuildable user data (playlists / track + /// state), so the app surfaces this to the user instead of wiping silently (S10.3 break-it). + public let quarantinedFrom: URL? + /// A count of FTS `SearchIndex` write operations (sync/delete) performed this /// session — a verification hook so the harness can prove a no-op re-scan does /// ZERO FTS writes (the idempotency contract, design §4). A `Mutex` because writes @@ -93,9 +98,12 @@ public final class LibraryStore: Sendable { /// - url: the store file URL (`:memory:` for an in-memory database). /// - appBuild: optional build identifier stored in `schema_info.app_build`. public init(url: URL, appBuild: String? = nil) async throws { - (dbWriter, version) = try LibraryStore.openMigratingAndRepairing( + let opened = try LibraryStore.openMigratingAndRepairing( url: url, appBuild: appBuild, stamp: StoreQuarantine.defaultStamp() ) + dbWriter = opened.writer + version = opened.version + quarantinedFrom = opened.quarantinedFrom } /// The default store location: `~/Library/Application Support/AdaptiveSound/ @@ -286,16 +294,25 @@ public final class LibraryStore: Sendable { /// fails — the file (+ its `-wal`/`-shm` sidecars) is quarantined and a fresh store is /// rebuilt. Never crashes, never silently deletes (design §5). A schema change is an /// ADDITIVE, frozen-body migration that PRESERVES user data (erase=false, S10.3). + /// Result of opening/migrating a store: the writer, the schema version reached, and — when a + /// pre-existing corrupt file was quarantined + rebuilt — the quarantined path (else nil). A + /// struct (not a 3-tuple) to stay within the large-tuple lint bound. + private struct OpenedStore { + let writer: any DatabaseWriter + let version: Int + let quarantinedFrom: URL? + } + private static func openMigratingAndRepairing( url: URL, appBuild: String?, stamp: String - ) throws -> (any DatabaseWriter, Int) { + ) throws -> OpenedStore { let migrator = makeMigrator(appBuild: appBuild) // In-memory stores can't be corrupt/quarantined; open + migrate directly. if url.path == ":memory:" || url.absoluteString == "file::memory:" { let queue = try DatabaseQueue(configuration: makeConfiguration()) try migrator.migrate(queue) - return try (queue, readSchemaVersion(queue)) + return try OpenedStore(writer: queue, version: readSchemaVersion(queue), quarantinedFrom: nil) } let fileExisted = FileManager.default.fileExists(atPath: url.path) @@ -323,15 +340,17 @@ public final class LibraryStore: Sendable { guard fileExisted else { throw error } throw StoreOpenFailure.rebuildRecoverable } - return try (pool, readSchemaVersion(pool)) + return try OpenedStore(writer: pool, version: readSchemaVersion(pool), quarantinedFrom: nil) } catch let error where fileExisted && isRebuildRecoverable(error) { // A pre-existing file was unusable (corrupt / failed integrity / newer schema / a - // schema the migrator can't apply). Quarantine it (+ sidecars) and rebuild fresh — - // the library is a rebuildable cache. - try StoreQuarantine.quarantine(storeURL: url, stamp: stamp) + // schema the migrator can't apply). Quarantine it (+ sidecars) and rebuild fresh — the + // DERIVED cache re-scans, but this file ALSO held user data (playlists / track state) + // that a rebuild CANNOT recover, so the quarantined path is surfaced (S10.3 break-it): + // the caller warns the user + points at the saved file rather than wiping in silence. + let quarantined = try StoreQuarantine.quarantine(storeURL: url, stamp: stamp) let pool = try DatabasePool(path: url.path, configuration: makeConfiguration()) try migrator.migrate(pool) - return try (pool, readSchemaVersion(pool)) + return try OpenedStore(writer: pool, version: readSchemaVersion(pool), quarantinedFrom: quarantined.first) } } @@ -363,8 +382,14 @@ public final class LibraryStore: Sendable { /// cache of on-disk files. A break-it pass showed the old "wipe on any schema change" rule /// silently destroyed that user data (and a separate never-erased store keyed by the reused /// `tracks.id` rowid mis-resolved playlists after a rebuild — worse). So migrations are - /// **additive-only** and every shipped migration body is **frozen** (a schema change is a NEW - /// appended migration — proven-safe: appending never erases). The DERIVED cache (scan-built + /// DATA-PRESERVING + APPEND-NEVER-EDIT (not merely "additive"): NEVER edit a shipped migration + /// body — GRDB keys bookkeeping on the IDENTIFIER, so an edit never re-runs on an already-migrated + /// store (fresh installs get it, existing users don't → their schema silently diverges, + /// `no such column` for upgraders only). A change is ALWAYS a NEW appended migration. This is NOT + /// "columns-only": a genuinely DESTRUCTIVE change is an APPENDED migration using SQLite's 12-step + /// table rebuild (new table → copy → drop → rename) — still data-preserving + append-only. + /// ENFORCED: the strict-gate grep pins `= false`; the `additive-migration-convergence` check pins + /// the schema fingerprint (an edited shipped body fails it). The DERIVED cache (scan-built /// track/album/artist/genre/FTS rows) is still rebuildable — by a RE-SCAN (delete rows + /// re-scan), not by wiping the file. Dev reset: add a migration, or delete the DB file by hand. static func makeMigrator(appBuild: String?) -> DatabaseMigrator { @@ -385,6 +410,9 @@ public final class LibraryStore: Sendable { migrator.registerMigration(Schema.MigrationID.v5) { db in try Schema.migrateV4toV5(db, appBuild: appBuild, timestamp: nowSeconds()) } + migrator.registerMigration(Schema.MigrationID.v6) { db in + try Schema.migrateV5toV6(db, appBuild: appBuild, timestamp: nowSeconds()) + } return migrator } diff --git a/Sources/LibraryStore/Schema.swift b/Sources/LibraryStore/Schema.swift index 86a3296..8745c02 100644 --- a/Sources/LibraryStore/Schema.swift +++ b/Sources/LibraryStore/Schema.swift @@ -32,7 +32,7 @@ import GRDB /// means: register a new migration in `LibraryStore.makeMigrator` (with a new `Schema.MigrationID`) /// whose body writes `schema_info` at the new version, AND bump this constant — the migration /// creates/backfills the schema; this constant is the value the harness expects to read back. -public let currentSchemaVersion = 5 +public let currentSchemaVersion = 6 /// The reserved "unknown artist" sentinel rowid seeded at v1 for the M1 album key. public let unknownArtistID: Int64 = 0 @@ -65,6 +65,12 @@ public enum Schema { /// playlists + entries (D-folder-delete "folder owns its contents"; undo is an app-layer /// subtree snapshot). This migration is now DURABLE (erase=false) — it never wipes. public static let v5 = "v5-playlist-folders" + /// v5 → v6: playlist-name uniqueness becomes CASE-INSENSITIVE (S10.3 break-it). The v3 index + /// was a binary compare, so "Rock" and "rock" could coexist — yet the reserved-name guard + /// (`validatedUserName`) and the sidebar sort are already NOCASE. This drops the binary index + /// and recreates it `COLLATE NOCASE`. ADDITIVE + data-preserving (it fails loudly, rolling + /// back, only if two case-colliding names already exist — impossible before real user data). + public static let v6 = "v6-playlist-name-nocase" } /// The complete set of `CREATE` statements for schema v1, ordered so a @@ -456,4 +462,22 @@ public extension Schema { try writeSchemaInfo(db, version: 5, appBuild: appBuild, createdAt: timestamp, migratedAt: timestamp) } + + /// v6 (S10.3): recreate the user-playlist-name unique index `COLLATE NOCASE` so uniqueness is + /// case-insensitive (matching the reserved-name guard + the NOCASE sort). Drop-then-create is + /// the standard index-collation change; it's a data-preserving APPENDED migration (never edits + /// v3's body). Throws + rolls back only if two case-colliding user names already exist. + static let createV6Statements: [String] = [ + "DROP INDEX IF EXISTS idx_playlists_name_user;", + "CREATE UNIQUE INDEX idx_playlists_name_user ON playlists(name COLLATE NOCASE) WHERE is_builtin = 0;", + ] + + /// Migrate v5 → v6: case-insensitive user-playlist-name uniqueness. Additive/data-preserving. + static func migrateV5toV6(_ db: Database, appBuild: String?, timestamp: Int64) throws { + for statement in createV6Statements { + try db.execute(sql: statement) + } + try writeSchemaInfo(db, version: 6, appBuild: appBuild, + createdAt: timestamp, migratedAt: timestamp) + } } diff --git a/Sources/VerifyLibraryStore/Checks.swift b/Sources/VerifyLibraryStore/Checks.swift index 543b2a4..eb8c612 100644 --- a/Sources/VerifyLibraryStore/Checks.swift +++ b/Sources/VerifyLibraryStore/Checks.swift @@ -68,6 +68,9 @@ func fullMigrator() -> DatabaseMigrator { migrator.registerMigration(Schema.MigrationID.v5) { db in try Schema.migrateV4toV5(db, appBuild: "verify", timestamp: testTimestamp) } + migrator.registerMigration(Schema.MigrationID.v6) { db in + try Schema.migrateV5toV6(db, appBuild: "verify", timestamp: testTimestamp) + } return migrator } diff --git a/Sources/VerifyLibraryStore/ChecksMigrationConvergence.swift b/Sources/VerifyLibraryStore/ChecksMigrationConvergence.swift new file mode 100644 index 0000000..57bf712 --- /dev/null +++ b/Sources/VerifyLibraryStore/ChecksMigrationConvergence.swift @@ -0,0 +1,99 @@ +// ChecksMigrationConvergence — S10.3 break-it (architect BLOCKER "Attack B"). The store now holds +// NON-rebuildable user data, so a migration mistake is a data-loss / crash bug. Two guards live +// here (the strict-gate grep covers "Attack A", re-enabling erase): +// 1. STAGED-UPGRADE CONVERGENCE — a long-time user who upgrades through app versions (reach v1, +// then apply the rest) must land on the SAME schema as a fresh install. +// 2. GOLDEN FINGERPRINT — a fresh full migrate must produce EXACTLY the committed schema. Editing +// a SHIPPED migration body changes it (a fresh install would then diverge from an already- +// migrated user still on the OLD body → `no such column` for UPGRADERS ONLY, invisible in +// dev). This fails loudly; a LEGIT new migration updates the golden hash in the same commit — +// a reviewable, intentional change, not a silent drift. + +import Foundation +import GRDB +import LibraryStore + +// MARK: - Registration + +func migrationConvergenceCheckCases() -> [CheckCase] { + [CheckCase(label: "additive-migration-convergence", run: { number, url in + checkAdditiveMigrationConvergence(number: number, url: url) + })] +} + +// MARK: - Golden + +/// FNV-1a hash of the expected schema fingerprint at `currentSchemaVersion`. Regenerate by running +/// VerifyLibraryStore after a NEW migration and pasting the printed FRESH hash — it should change +/// ONLY when a migration is appended, never from editing a shipped one. (A hash, not the full DDL, +/// so the constant stays one short line.) +let goldenSchemaFingerprintHashV6 = "ba691c5ab17c37ab" + +// MARK: - Fingerprint + +/// A normalized fingerprint of the store's SCHEMA: the DDL of every app object in `sqlite_master` +/// (whitespace-collapsed so pure reformatting doesn't churn it), excluding GRDB/SQLite internals. +/// DATA (row contents, timestamps) is NOT included — only structure. +func schemaFingerprint(_ db: Database) throws -> String { + let rows = try Row.fetchAll(db, sql: """ + SELECT type, name, COALESCE(sql, '') AS sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND name NOT LIKE 'grdb_%' + ORDER BY type, name; + """) + return rows.map { row in + let type: String = row["type"] + let name: String = row["name"] + let sql: String = row["sql"] + let normalized = sql.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty }.joined(separator: " ") + return "\(type)|\(name)|\(normalized)" + }.joined(separator: "\n") +} + +/// Deterministic FNV-1a 64-bit hash (hex) — stable across runs/machines (unlike Swift's seeded +/// `Hasher`), so it's usable as a committed golden. +func fnv1a64Hex(_ string: String) -> String { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return String(format: "%016llx", hash) +} + +// MARK: - Check + +func checkAdditiveMigrationConvergence(number: Int, url _: URL) -> Bool { + do { + // Fresh install: full migrate from empty in one shot (in-memory — no file needed). + let fresh = try DatabaseQueue() + try fullMigrator().migrate(fresh) + let freshFingerprint = try fresh.read { db in try schemaFingerprint(db) } + + // Staged upgrade: reach v1, THEN run the full migrator (applies v2..vN onto the v1 store) — + // as a long-time user receives app updates over time. Must converge to the same schema. + let staged = try DatabaseQueue() + try v1OnlyMigrator().migrate(staged) + try fullMigrator().migrate(staged) + let stagedFingerprint = try staged.read { db in try schemaFingerprint(db) } + + guard freshFingerprint == stagedFingerprint else { + printFail(number, "staged upgrade schema diverged from a fresh install:\nFRESH:\n" + + "\(freshFingerprint)\nSTAGED:\n\(stagedFingerprint)") + return false + } + let freshHash = fnv1a64Hex(freshFingerprint) + guard freshHash == goldenSchemaFingerprintHashV6 else { + printFail(number, "schema fingerprint drift — a SHIPPED migration body changed (or a new " + + "migration landed without updating goldenSchemaFingerprintHashV6). If this is an " + + "intentional NEW migration, set the golden to: \(freshHash)\nFingerprint:\n\(freshFingerprint)") + return false + } + printPass(number, "additive-migration convergence: staged upgrade == fresh install, and the " + + "schema fingerprint hash matches the golden (no shipped-body drift)") + return true + } catch { + printFail(number, "additive-migration-convergence threw: \(error)") + return false + } +} diff --git a/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift b/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift index 2304389..e3cbb05 100644 --- a/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift +++ b/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift @@ -12,6 +12,9 @@ func playlistFolderCheckCases() -> [CheckCase] { CheckCase(label: "pl-folder-crud", run: checkFolderCRUD), CheckCase(label: "pl-folder-reparent-cycle-reject", run: checkFolderReparentCycle), CheckCase(label: "pl-folder-cascade-delete-restore", run: checkFolderCascadeDeleteRestore), + CheckCase(label: "pl-folder-restore-conflict-rolls-back", run: checkFolderRestoreConflictRollsBack), + CheckCase(label: "pl-set-playlist-folder-rejects", run: checkSetPlaylistFolderRejects), + CheckCase(label: "pl-folder-edge-cases", run: checkFolderEdgeCases), ] } @@ -123,3 +126,113 @@ func checkFolderCascadeDeleteRestore(number: Int, url: URL) async -> Bool { return true } catch { printFail(number, "pl-folder-cascade-delete-restore threw: \(error)"); return false } } + +/// pl-folder-restore-conflict-rolls-back: if the world changed between delete and undo (a deleted +/// playlist's NAME is reused before restore), `restoreFolderSubtree` must THROW and leave the store +/// UNCHANGED — never a half-inserted subtree (the single-txn + deferred-FK contract; QA break-it #2). +func checkFolderRestoreConflictRollsBack(number: Int, url: URL) async -> Bool { + do { + let store = try await LibraryStore(url: url, appBuild: "verify") + let folder = try await store.createFolder(name: "F", parentID: nil) + let p1 = try await store.createPlaylist(name: "Keeper") + try await store.setPlaylistFolder(playlistID: p1, folderID: folder) + let snapshot = try await store.deleteFolder(id: folder) // p1 + folder gone (cascade) + // Reuse the deleted playlist's name on a NEW playlist → restore's INSERT must hit the + // (NOCASE) unique index and roll the whole restore back. + _ = try await store.createPlaylist(name: "keeper") // NOCASE collision with "Keeper" + let foldersBefore = try await store.folders().map(\.id).sorted() + let playlistsBefore = try await store.playlists().map(\.id).sorted() + var threw = false + do { try await store.restoreFolderSubtree(snapshot) } catch { threw = true } + guard threw else { printFail(number, "restore did not throw on a name collision"); return false } + let foldersAfter = try await store.folders().map(\.id).sorted() + let playlistsAfter = try await store.playlists().map(\.id).sorted() + guard foldersAfter == foldersBefore, playlistsAfter == playlistsBefore else { + printFail(number, "restore left PARTIAL state after a rolled-back conflict " + + "(folders \(foldersBefore)->\(foldersAfter), playlists \(playlistsBefore)->\(playlistsAfter))") + return false + } + printPass(number, "restore THROWS + rolls back cleanly on a NOCASE name collision — no partial subtree") + return true + } catch { printFail(number, "pl-folder-restore-conflict-rolls-back threw: \(error)"); return false } +} + +/// pl-set-playlist-folder-rejects: `setPlaylistFolder` rejects the built-in "current" playlist +/// (`.builtinImmutable`) and a missing playlist / missing folder (`.notFound`) — not a silent no-op. +func checkSetPlaylistFolderRejects(number: Int, url: URL) async -> Bool { + do { + let store = try await LibraryStore(url: url, appBuild: "verify") + let builtinID = try await store.bootstrapBuiltinCurrentPlaylist() + let folder = try await store.createFolder(name: "F", parentID: nil) + var builtinRejected = false + do { + try await store.setPlaylistFolder(playlistID: builtinID, folderID: folder) + } catch PlaylistMutationError.builtinImmutable { + builtinRejected = true + } + var missingPlaylist = false + do { + try await store.setPlaylistFolder(playlistID: 999_999, folderID: folder) + } catch PlaylistMutationError.notFound { + missingPlaylist = true + } + let p1 = try await store.createPlaylist(name: "P1") + var missingFolder = false + do { + try await store.setPlaylistFolder(playlistID: p1, folderID: 999_999) + } catch PlaylistMutationError.notFound { + missingFolder = true + } + guard builtinRejected, missingPlaylist, missingFolder else { + printFail(number, "setPlaylistFolder guards wrong (builtin=\(builtinRejected) " + + "missingPlaylist=\(missingPlaylist) missingFolder=\(missingFolder))") + return false + } + printPass(number, "setPlaylistFolder rejects the built-in (.builtinImmutable) + missing " + + "playlist/folder (.notFound)") + return true + } catch { printFail(number, "pl-set-playlist-folder-rejects threw: \(error)"); return false } +} + +/// pl-folder-edge-cases: empty-folder delete+restore; reparent-to-nil (back to root); and a deep +/// (40-level) chain delete → cascade snapshot → verbatim restore (recursion + ordering under depth). +func checkFolderEdgeCases(number: Int, url: URL) async -> Bool { + do { + let store = try await LibraryStore(url: url, appBuild: "verify") + // Empty-folder delete + restore. + let empty = try await store.createFolder(name: "Empty", parentID: nil) + let emptySnap = try await store.deleteFolder(id: empty) + guard try await store.folders().first(where: { $0.id == empty }) == nil else { + printFail(number, "empty folder not deleted"); return false + } + try await store.restoreFolderSubtree(emptySnap) + guard try await store.folders().contains(where: { $0.id == empty }) else { + printFail(number, "empty folder not restored"); return false + } + // reparent-to-nil: a nested folder moves back to root. + let parent = try await store.createFolder(name: "P", parentID: nil) + let child = try await store.createFolder(name: "C", parentID: parent) + try await store.reparentFolder(id: child, newParentID: nil) + guard try await store.folders().first(where: { $0.id == child })?.parentID == nil else { + printFail(number, "reparent-to-nil did not move to root"); return false + } + // Deep chain: 40 nested levels, delete the root → whole chain cascades + restores. + var parentID: Int64? + var deepIDs: [Int64] = [] + for level in 0 ..< 40 { + let id = try await store.createFolder(name: "L\(level)", parentID: parentID) + deepIDs.append(id); parentID = id + } + let deepSnap = try await store.deleteFolder(id: deepIDs[0]) + guard deepSnap.folders.count == 40 else { + printFail(number, "deep-nest snapshot has \(deepSnap.folders.count) folders, expected 40"); return false + } + try await store.restoreFolderSubtree(deepSnap) + let restored = try await store.folders() + guard deepIDs.allSatisfy({ id in restored.contains(where: { $0.id == id }) }) else { + printFail(number, "deep-nest restore incomplete"); return false + } + printPass(number, "folder edge cases: empty delete+restore, reparent-to-nil, 40-level cascade+restore") + return true + } catch { printFail(number, "pl-folder-edge-cases threw: \(error)"); return false } +} diff --git a/Sources/VerifyLibraryStore/main.swift b/Sources/VerifyLibraryStore/main.swift index 638fc77..260ad06 100644 --- a/Sources/VerifyLibraryStore/main.swift +++ b/Sources/VerifyLibraryStore/main.swift @@ -180,6 +180,7 @@ func allCheckCases() -> [CheckCase] { + reachabilityCheckCases() + browseReadsCheckCases() + searchCheckCases() + songsSortCheckCases() + decodeCoverageCheckCases() + hardeningCheckCases() + playlistSpineCheckCases() + playlistFolderCheckCases() + + migrationConvergenceCheckCases() } /// GRDB hardening (post-adoption follow-ups): single-pool DatabasePool concurrency + diff --git a/Tests/LibraryBrowseKitTests/PlaylistBrowseVisibilityTests.swift b/Tests/LibraryBrowseKitTests/PlaylistBrowseVisibilityTests.swift new file mode 100644 index 0000000..7bcf542 --- /dev/null +++ b/Tests/LibraryBrowseKitTests/PlaylistBrowseVisibilityTests.swift @@ -0,0 +1,38 @@ +import LibraryBrowseKit +import LibraryStore +import Testing + +// MARK: - PlaylistBrowseVisibility (S10.3 — built-in never leaks into the browse UI) + +@Suite("PlaylistBrowseVisibility — built-in exclusion") +struct PlaylistBrowseVisibilityTests { + private func playlist(_ id: Int64, _ name: String, builtin: Bool) -> Playlist { + Playlist(id: id, name: name, isBuiltin: builtin, createdAt: 0, entryCount: 0) + } + + /// VIS-1 — the built-in "current" playlist is never user-visible; a user playlist always is. + @Test("built-in excluded, user included") + func singlePredicate() { + #expect(PlaylistBrowseVisibility.isUserVisible(playlist(1, "current", builtin: true)) == false) + #expect(PlaylistBrowseVisibility.isUserVisible(playlist(2, "Rock", builtin: false)) == true) + } + + /// VIS-2 — filtering drops the built-in wherever it sits and preserves the order of the rest. + @Test("userVisible drops built-in, preserves order") + func filterPreservesOrder() { + let input = [ + playlist(10, "A", builtin: false), + playlist(1, "current", builtin: true), + playlist(11, "B", builtin: false), + ] + let visible = PlaylistBrowseVisibility.userVisible(input) + #expect(visible.map(\.id) == [10, 11]) + } + + /// VIS-3 — an all-built-in (or empty) input yields nothing. + @Test("only built-in → empty") + func onlyBuiltin() { + #expect(PlaylistBrowseVisibility.userVisible([playlist(1, "current", builtin: true)]).isEmpty) + #expect(PlaylistBrowseVisibility.userVisible([]).isEmpty) + } +} diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md index 58ed0de..a6031e5 100644 --- a/docs/sprints/s10-3-playlists-ux-design.md +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -57,8 +57,8 @@ Adjacency-list `playlist_folders(id, parent_id NULL self-ref, name, position, cr ## 6. Chunking (phased, build-gated) - **A0** — ✅ DONE: `library.sqlite3` → `eraseDatabaseOnSchemaChange = false` (additive-only, protects playlists + track user-state) + the `additive-preserve-schema-bump` green-gate. *(The separate-store detour was built then reverted per the QA break-it — §0.)* -- **A** — `playlist_folders` schema as an **additive v5 migration** + folder DAO (CRUD/reparent/cycle-guard/cascade-delete-with-subtree-snapshot for undo) + `PlaylistsModel` peer over `library.store`. (Deleted-track cleanup is the same-file FK cascade — no sweep needed.) -- **B** — sidebar Playlists section (flat first): one ScrollView/LazyVStack, unified selection enum, built-in filtered, scroll, select→detail, create/inline-rename/delete. +- **A** — ✅ DONE (7392b1e): `playlist_folders` additive v5 migration + folder DAO (CRUD/reparent/cycle-guard/cascade-delete-with-subtree-snapshot for undo). (Deleted-track cleanup is the same-file FK cascade.) `PlaylistsModel` moved to B (kept with its UI consumer so it isn't dead code under Periphery). +- **B** — ✅ DONE (8277ab8): `PlaylistsModel` peer + composition wiring + sidebar Playlists section (one ScrollView/LazyVStack, unified selection enum, built-in filtered, scroll, select→detail, create/inline-rename/delete) + read-only `PlaylistDetailView`. Plus the A+B review-fix batch (§8). - **C** — `PlaylistDetailList` (queue-row reuse): play (verbs + undo), remove, reorder; `ForEach` on entry id. - **D** — folders UI: flattened disclosure tree, create/rename/delete(+undo), drag-reparent, cycle-guard, depth cap. - **E** — add-to-playlist: context menu + searchable sheet (US-PLIST-02 incl. loose file) + `LibraryTrackDragItem` UTType + drops (US-PLIST-03/04); `PlaylistDropRouter` + FileMover-spy + strict-gate grep **here**. @@ -69,6 +69,21 @@ Adjacency-list `playlist_folders(id, parent_id NULL self-ref, name, position, cr - **Play a folder** — *recommended:* plays all descendant playlists' tracks in tree order (bounded by a queue-size safety cap). Same for a multi-playlist selection. - **Backup/export of user data** — *recommended:* out of R1 scope, but note as the belt-and-braces follow-up (the corruption/quarantine rebuild of `library.sqlite3` still loses user data — erase=false only protects the schema-change path, not corruption). Not blocking. +## 8. Break-it outcomes (A+B, 2026-07-16) + founder decisions +Chunks A+B passed swift-expert + swiftui-pro reviews and a qa-expert + architect/Fool break-it. Both reviewers **steelmanned the one-store reversal + the sidebar rebuild as correct**. Fixes landed in the A+B review-fix batch; founder decisions locked: +- **Migrator enforcement (was: no guard on the REAL migrator — the additive-preserve check only proved GRDB's semantics):** the strict-gate now GREPs to forbid `eraseDatabaseOnSchemaChange = true` and assert `= false` (Attack A), and the `additive-migration-convergence` VerifyLibraryStore check pins a golden schema fingerprint + staged==fresh convergence (Attack B — an edited shipped migration body silently diverging existing users). The `makeMigrator` invariant is reworded to **data-preserving, append-never-edit** (+ the 12-step table-rebuild escape hatch for destructive changes). +- **Corruption/quarantine (was: silent total wipe):** a quarantine-rebuild now surfaces the shell error banner (`LibraryStore.quarantinedFrom` → `LibraryModel.onError`), naming the saved file. The whole-DB `VACUUM INTO` sibling-backup is a documented **FAST-FOLLOW** (the remaining belt-and-braces for the corruption path). +- **D-names → case-INSENSITIVE:** v6 migration recreates `idx_playlists_name_user` `COLLATE NOCASE` + a NOCASE conflict probe (matches the reserved-name guard + the NOCASE sort). "Rock" == "rock". +- **Folder DAO hardened (swift-expert):** dedicated `.wouldCreateCycle`; `setPlaylistFolder` throws `.notFound`/`.builtinImmutable`; `reparentFolder` re-positions to end-of-new-group; restore-undo best-effort (throws-not-corrupts) doc. New checks: `pl-folder-restore-conflict-rolls-back`, `pl-set-playlist-folder-rejects`, `pl-folder-edge-cases` (empty / reparent-nil / 40-level). Built-in exclusion extracted to the pure, unit-tested `PlaylistBrowseVisibility`. +- **App fixes (swiftui-pro + qa):** `closeDetail` bumps the detail epoch (no ghost republish); delete redirects nav only on confirmed success; rename captures the draft synchronously + re-checks the row after the await + a reserved-name message; click-away commits (reverts on conflict); deferred rename-focus; VoiceOver selection traits; ↑/↓ ignored during a drill-down. + +### Deferred to later chunks (carry forward) +- **Chunk C:** generalize `PlaylistItemRow`'s drag payload (hardwired to `QueueDragItem`; playlist reorder needs `PlaylistEntryDragItem`) + model playlist "now playing" as an explicit `activeEntryID` (not the queue-cursor `isNowPlaying`). +- **Chunk C/E:** a playlist-internal mutation (add/remove/reorder) MUST call `loadTree()` — entry-count badges don't ride `libraryRevision`. +- **Before Chunk D:** collapse the dual source of truth for "which playlist is open" (`LibraryBrowseModel.path.last` vs `PlaylistsModel.openPlaylistID`) so a folder cascade-delete can't orphan a ghost detail (D also clears `openPlaylistID` for a deleted subtree via the snapshot ids). The `closeDetail` epoch bump mitigates the model-state inconsistency in the interim. +- **Chunk F:** replace `PlaylistDetailEntry.display == nil` with an explicit `resolution: .ok | .missing` — a moved file keeps its id (resolves) and a deleted track's entry is cascade-dropped, so nil-display is near-vestigial; the real dead case is a LOOSE file gone from disk. +- **Watch:** the `LoadState`/epoch pattern is duplicated across `LibraryBrowseModel`/`PlaylistsModel` — extract at the 3rd copy (smart playlists). + --- ## Deferred to post-R1 — Smart / auto playlists (design preserved) diff --git a/docs/sprints/sprint-plan.md b/docs/sprints/sprint-plan.md index e3a07ac..23bd87b 100644 --- a/docs/sprints/sprint-plan.md +++ b/docs/sprints/sprint-plan.md @@ -69,7 +69,7 @@ The rationale is sound: **an audiophile player lives or dies on library + playba ### Status -**S6–S9 ✅ + S10.1 ✅ + S10.2 ✅ + S10.6 ✅ shipped · S10.4 in progress → then S10.3 → Release R1.** S9 browse complete; S10.1 (playlist/queue spine), S10.2 (queue UX), and S10.6 (Recently Played — frecency) shipped & merged. Current work: **S10.4** (macOS system control — media keys + Now Playing/Control Center + shortcuts; folds in the on-screen now-playing metadata fix — [design](s10-4-macos-system-control-design.md)); then **S10.3** (Playlists UX) closes R1. **R1 gates on S10.1–S10.4** (S10.5/S10.6 are enhancements/polish, not gates). Deprioritized 2026-07-14 (founder): drag-from-Library-into-queue + M3U/M3U8 import-export (see [roadmap Deferred](../product/roadmap.md)). **This line is the single prose status surface for the project** — README/roadmap defer here; everything finer-grained lives in the source + git log (which are authoritative if they disagree with this). +**S6–S9 ✅ + S10.1 ✅ + S10.2 ✅ + S10.4 ✅ (#58) + S10.6 ✅ shipped · S10.3 in progress (last R1 gate).** S9 browse complete; S10.1 (playlist/queue spine), S10.2 (queue UX), S10.4 (macOS system control — media keys + Now Playing/Control Center), and S10.6 (Recently Played — frecency) shipped & merged. Current work: **S10.3** (Playlists UX — [design](s10-3-playlists-ux-design.md)), scoped to include playlist FOLDERS + case-insensitive names; smart playlists decoupled to a post-R1 fast-follow. Progress: chunk A (folder DAO + additive v5/v6 migrations) + chunk B (PlaylistsModel + sidebar Playlists section + read-only detail) landed on the branch, hardened by a qa-expert + architect/Fool break-it (migrator-posture strict-gate guard + schema-convergence check; corruption-signal; folder data-safety checks). Remaining: play verbs + undo, folders UI, add-to-playlist + drag/drop, dead-file handling, then R1. **R1 gates on S10.1–S10.4 + S10.3** (S10.5/S10.6 are enhancements/polish, not gates). Deprioritized 2026-07-14 (founder): drag-from-Library-into-queue + M3U/M3U8 import-export (see [roadmap Deferred](../product/roadmap.md)). **This line is the single prose status surface for the project** — README/roadmap defer here; everything finer-grained lives in the source + git log (which are authoritative if they disagree with this). --- diff --git a/scripts/strict-gate.sh b/scripts/strict-gate.sh index b5768e2..dab9e85 100755 --- a/scripts/strict-gate.sh +++ b/scripts/strict-gate.sh @@ -83,6 +83,25 @@ semgrep scan --config .semgrep.yml --error --quiet step "Suppression policy (owner/reason/expiry accountability)" bash scripts/check-suppressions.sh +step "Migrator posture guard (user data must never be erased on a schema change)" +# S10.3 ONE-STORE posture: LibraryStore holds NON-rebuildable USER data (playlists/folders + the +# track user-state columns), so a schema change must NEVER wipe it. Forbid ever re-enabling GRDB's +# eraseDatabaseOnSchemaChange, and assert the intended `= false` line is present — so a merge or +# refactor can't silently resurrect the old drop-and-recreate posture (break-it "Attack A"). The +# companion `additive-migration-convergence` VerifyLibraryStore check covers "Attack B" (an edited +# shipped migration body silently diverging existing users' schema). +migrator_src="Sources/LibraryStore/LibraryStore.swift" +if grep -nE 'eraseDatabaseOnSchemaChange[[:space:]]*=[[:space:]]*true' "$migrator_src"; then + red "ERROR: eraseDatabaseOnSchemaChange = true in $migrator_src — this WIPES all user data on a" + red " schema change. Forbidden (S10.3 one-store posture holds playlists + track user-state)." + exit 1 +fi +if ! grep -qE 'eraseDatabaseOnSchemaChange[[:space:]]*=[[:space:]]*false' "$migrator_src"; then + red "ERROR: '$migrator_src' must explicitly pin 'eraseDatabaseOnSchemaChange = false' (S10.3)." + exit 1 +fi +green "migrator posture ok (eraseDatabaseOnSchemaChange pinned false; never-erase)." + if [[ "$HAVE_CPPCHECK" == "1" ]]; then step "cppcheck (supplementary C++ analysis)" # Start strict-but-useful: warnings/style/perf/portability, not --enable=all (which is From e79dd442c0d79efbee3b617fd783fd42d41af970 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:30:23 +0530 Subject: [PATCH 10/15] =?UTF-8?q?feat(s10.3=20C):=20playlist=20detail=20?= =?UTF-8?q?=E2=80=94=20play=20verbs=20+=20restore-queue=20undo=20+=20reord?= =?UTF-8?q?er=20+=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the playlist detail interactive (design §4 / D-play), reusing the queue's row scaffold. - PlaylistItemRow generalized to PlaylistItemRow so the queue (QueueDragItem) and the playlist detail (PlaylistEntryDragItem) share one row (architect review #4). Queue behavior unchanged. - PlaylistsModel play verbs over `audio` (now wired): playPlaylist (REPLACE with a one-level restore-queue undo), playPlaylistNext / appendPlaylist, per-row playEntryNext; removeEntry (+ loadTree for the count badge) and reorderEntries. resolvedEntries drops unavailable tracks = skip-on-play (F adds the badge). - AudioViewModel.playNowWithUndo + restorePreviousQueue + QueueRestorePoint: snapshot the queue before a Play-replace; restore = empty→clear, else resume at the prior track (no-selection→restore slots + stop the engine). - PlaylistDetailView: header Play/Next/Queue, tap-to-play-from-row, grip-drag reorder (queue's from [AudioFile] { + resolvedEntries.compactMap(\.display).map { AudioFile($0) } + } + + /// Play the open playlist NOW — REPLACES the queue, with a one-level "Restore previous queue" + /// undo (D-play). Starts at `entryID` (a tapped row) or the top. Returns whether it actually + /// replaced (false if nothing is playable) so the caller only raises the undo toast on a replace. + @discardableResult + func playPlaylist(startingAt entryID: Int64? = nil) -> Bool { + let entries = resolvedEntries + let files = entries.compactMap(\.display).map { AudioFile($0) } + guard !files.isEmpty else { return false } + let start = entryID.flatMap { id in entries.firstIndex { $0.id == id } } ?? 0 + audio.playNowWithUndo(files, startAt: start) + return true + } + + /// Insert the WHOLE open playlist's tracks right after the current one (header Play Next). + @discardableResult + func playPlaylistNext() -> Int { + let files = playableFiles() + guard !files.isEmpty else { return 0 } + return audio.playNext(files) + } + + /// Insert a SINGLE entry's track right after the current one (per-row "Play Next"). Returns count. + @discardableResult + func playEntryNext(_ entryID: Int64) -> Int { + guard let display = resolvedEntries.first(where: { $0.id == entryID })?.display else { return 0 } + return audio.playNext([AudioFile(display)]) + } + + /// Append the open playlist's tracks to the end of the queue (Add to Queue). Returns the count. + @discardableResult + func appendPlaylist() -> Int { + let files = playableFiles() + guard !files.isEmpty else { return 0 } + return audio.appendToQueue(files) + } + + /// Whether a "Restore previous queue" undo is available (a Play-replace happened). + var canRestorePreviousQueue: Bool { + audio.canRestorePreviousQueue + } + + /// Restore the queue that a "Play" replaced (one-level undo). + func restorePreviousQueue() { + audio.restorePreviousQueue() + } + + // MARK: - Entry mutation (C) + + /// Remove one entry from the open playlist, then reload the detail AND the tree (the sidebar + /// count badge doesn't ride `libraryRevision`, so an entry mutation must reload it — design §8). + func removeEntry(_ entryID: Int64) async { + guard let store, let playlistID = openPlaylistID else { return } + do { + try await store.removeEntry(id: entryID, playlistID: playlistID) + await loadDetail(id: playlistID) + await loadTree() + } catch { + detailState = .failed(error.localizedDescription) + } + } + + /// Reorder the open playlist to `orderedEntryIDs` (dense renumber in the DAO), then reload the + /// detail. Count is unchanged, so the tree isn't reloaded. + func reorderEntries(_ orderedEntryIDs: [Int64]) async { + guard let store, let playlistID = openPlaylistID else { return } + do { + try await store.reorderPlaylist(id: playlistID, entryIDsInOrder: orderedEntryIDs) + await loadDetail(id: playlistID) + } catch { + detailState = .failed(error.localizedDescription) + } + } } // MARK: - Detail row model diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift index 1cad2ba..5e22ac7 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift @@ -4,14 +4,25 @@ import SwiftUI // MARK: - Playlist detail (S10.3 — the open-playlist content pane) /// The detail shown when a playlist is selected in the sidebar. Loads through `PlaylistsModel` -/// (entries in position order, each resolved to its library track) and renders the rows READ-ONLY -/// by reusing `PlaylistItemRow` in its no-drag form. Chunk C wires play / remove / reorder onto -/// these SAME rows (tap-to-play, the header play verbs + restore-queue undo, grip-drag reorder); +/// (entries in position order, each resolved to its library track) and reuses `PlaylistItemRow` +/// (with a `PlaylistEntryDragItem` grip). Chunk C: the three play verbs (Play replaces the queue +/// with a one-level restore-queue undo; Play Next / Add to Queue), tap-to-play-from-row, grip-drag +/// reorder, remove, and ↑/↓/Return/⌫ keys — the same scaffold the queue's `PlaylistItemList` uses. /// Chunk F renders the unavailable state for an entry whose file moved/was deleted. struct PlaylistDetailView: View { let playlistID: Int64 @Environment(PlaylistsModel.self) private var model + /// Keyboard-selected row (a ScrollView/LazyVStack doesn't own key focus like a `List`). + @State private var selectedEntryID: Int64? + /// The entry a reorder drag is hovering over (drop-target border). Nil when no drag is active. + @State private var dropTargetEntryID: Int64? + @FocusState private var listFocused: Bool + /// Transient "Restore previous queue" affordance after a Play-replace; the token re-triggers the + /// auto-dismiss even on a repeated Play. + @State private var restoreToastToken: Int? + @State private var restoreDismissTask: Task? + /// Track-number column sized to the widest index, so a 3-digit position never wraps (queue idiom). private var numberColumnWidth: CGFloat { let digits = max(2, String(model.detail.count).count) @@ -26,6 +37,7 @@ struct PlaylistDetailView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(DesignSystem.Color.window) + .overlay(alignment: .bottom) { restoreToast } // Reload whenever the selected playlist changes (a new sidebar selection reuses this view). .task(id: playlistID) { await model.loadDetail(id: playlistID) } } @@ -33,7 +45,7 @@ struct PlaylistDetailView: View { // MARK: Header private var header: some View { - HStack(alignment: .firstTextBaseline) { + HStack(alignment: .firstTextBaseline, spacing: DesignSystem.Spacing.medium) { VStack(alignment: .leading, spacing: 2) { Text("Playlist") .font(DesignSystem.Font.micro) @@ -46,6 +58,7 @@ struct PlaylistDetailView: View { .lineLimit(1) } Spacer() + playVerbs Text(countLine) .font(DesignSystem.Font.monoSmall) .foregroundStyle(DesignSystem.Color.labelTertiary) @@ -54,6 +67,25 @@ struct PlaylistDetailView: View { .padding(.vertical, DesignSystem.Spacing.medium) } + private var playVerbs: some View { + HStack(spacing: DesignSystem.Spacing.small) { + Button { playNow() } label: { Label("Play", systemImage: "play.fill") } + .buttonStyle(.borderedProminent) + .tint(DesignSystem.Color.accent) + Button { _ = model.playPlaylistNext() } label: { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + .labelStyle(.iconOnly) + .help("Play Next") + Button { _ = model.appendPlaylist() } label: { + Label("Add to Queue", systemImage: "text.append") + } + .labelStyle(.iconOnly) + .help("Add to Queue") + } + .disabled(model.detailState != .loaded) + } + private var countLine: String { let count = model.detail.count return "\(count.formatted(.number)) \(count == 1 ? "track" : "tracks")" @@ -92,23 +124,51 @@ struct PlaylistDetailView: View { } } } + .focusable() + .focused($listFocused) + .focusEffectDisabled() .frame(maxWidth: .infinity, maxHeight: .infinity) + .onKeyPress(.upArrow) { moveSelection(by: -1) } + .onKeyPress(.downArrow) { moveSelection(by: 1) } + .onKeyPress(.return) { playSelected() } + .onKeyPress(.delete) { removeSelected() } } @ViewBuilder private func detailRow(index: Int, row: PlaylistDetailEntry) -> some View { if let display = row.display { - // Read-only reuse: nil `dragPayload` → no grip; no tap gesture (Chunk C adds play/reorder). PlaylistItemRow( file: AudioFile(display), index: index, - isSelected: false, - isNowPlaying: false, - numberColumnWidth: numberColumnWidth + isSelected: selectedEntryID == row.id, + isNowPlaying: false, // "now playing" in a playlist context is deferred (architect #4) + numberColumnWidth: numberColumnWidth, + dragPayload: PlaylistEntryDragItem(entryID: row.id), + isDropTarget: dropTargetEntryID == row.id ) + .dropDestination(for: PlaylistEntryDragItem.self) { payloads, _ in + dropTargetEntryID = nil + guard let fromID = payloads.first?.entryID else { return false } + return moveEntry(fromID: fromID, toEntryID: row.id) + } isTargeted: { targeted in + dropTargetEntryID = targeted ? row.id : (dropTargetEntryID == row.id ? nil : dropTargetEntryID) + } + .simultaneousGesture(TapGesture().onEnded { + listFocused = true + selectedEntryID = row.id + playNow(startingAt: row.id) + }) + .accessibilityAddTraits(.isButton) + .accessibilityAction { playNow(startingAt: row.id) } + .contextMenu { + Button("Play") { playNow(startingAt: row.id) } + Button("Play Next") { _ = model.playEntryNext(row.id) } // this track, not the whole list + Divider() + Button("Remove from Playlist", role: .destructive) { + Task { await model.removeEntry(row.id) } + } + } } else { - // Placeholder for an unresolved track (moved/deleted). Chunk F replaces this with a - // proper "unavailable" badge + Locate / Remove-missing affordances. unavailableRow(index: index) } } @@ -128,4 +188,99 @@ struct PlaylistDetailView: View { .padding(.horizontal, DesignSystem.Spacing.small) .frame(maxWidth: .infinity, alignment: .leading) } + + // MARK: Restore-queue undo toast + + @ViewBuilder private var restoreToast: some View { + if restoreToastToken != nil, model.canRestorePreviousQueue { + HStack(spacing: DesignSystem.Spacing.medium) { + Text("Queue replaced") + .font(DesignSystem.Font.caption) + .foregroundStyle(DesignSystem.Color.label) + Button("Restore previous queue") { + model.restorePreviousQueue() + dismissRestoreToast() + } + .buttonStyle(.plain) + .foregroundStyle(DesignSystem.Color.accent) + } + .padding(.horizontal, DesignSystem.Spacing.medium) + .padding(.vertical, DesignSystem.Spacing.small) + .background(.bar, in: Capsule()) + .overlay(Capsule().stroke(DesignSystem.Color.hairline, lineWidth: 0.5)) + .padding(.bottom, DesignSystem.Spacing.large) + .transition(.opacity) + } + } + + // MARK: Actions + + /// Play the playlist (replace queue, undoable), optionally from a tapped row, and raise the + /// transient restore-queue affordance — ONLY if a replace actually happened (an all-unavailable + /// playlist no-ops, and must not resurface a stale toast from an earlier real Play). + private func playNow(startingAt entryID: Int64? = nil) { + if model.playPlaylist(startingAt: entryID) { raiseRestoreToast() } + } + + private func raiseRestoreToast() { + let token = (restoreToastToken ?? 0) &+ 1 + restoreToastToken = token + restoreDismissTask?.cancel() + restoreDismissTask = Task { [token] in + try? await Task.sleep(for: .seconds(6)) + guard !Task.isCancelled, restoreToastToken == token else { return } + restoreToastToken = nil + } + } + + private func dismissRestoreToast() { + restoreDismissTask?.cancel() + restoreToastToken = nil + } + + /// Move `fromID` to land AT `toEntryID`'s row and persist the new order — matching the queue's + /// convention (`AudioViewModel.moveByDrop`): dragging DOWN inserts AFTER the target, UP before + /// it, so the last slot is reachable and the two lists behave identically. + private func moveEntry(fromID: Int64, toEntryID: Int64) -> Bool { + var ids = model.detail.map(\.id) + guard let from = ids.firstIndex(of: fromID), let to = ids.firstIndex(of: toEntryID), + from != to else { return false } + ids.move(fromOffsets: IndexSet(integer: from), toOffset: from < to ? to + 1 : to) + Task { await model.reorderEntries(ids) } + return true + } + + /// ↑/↓ traverse only the RESOLVED (playable) rows — the "unavailable" placeholders are + /// non-interactive until Chunk F, so selection never lands on one. + private func moveSelection(by delta: Int) -> KeyPress.Result { + let ids = model.detail.filter { $0.display != nil }.map(\.id) + guard !ids.isEmpty else { return .ignored } + guard let current = selectedEntryID, let index = ids.firstIndex(of: current) else { + selectedEntryID = ids.first + return .handled + } + let next = index + delta + guard next >= 0, next < ids.count else { return .ignored } + selectedEntryID = ids[next] + return .handled + } + + private func playSelected() -> KeyPress.Result { + guard let id = selectedEntryID else { return .ignored } + playNow(startingAt: id) + return .handled + } + + private func removeSelected() -> KeyPress.Result { + guard let id = selectedEntryID else { return .ignored } + // Pre-select the neighbor (next, else previous) so selection lands there — not back at the + // top — once the async remove + reload lands (the neighbor id survives the reload). + let ids = model.detail.map(\.id) + if let index = ids.firstIndex(of: id) { + selectedEntryID = index + 1 < ids.count ? ids[index + 1] + : (index - 1 >= 0 ? ids[index - 1] : nil) + } + Task { await model.removeEntry(id) } + return .handled + } } diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift index c84e703..1c0da06 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift @@ -2,7 +2,7 @@ import SwiftUI // MARK: - Playlist Item Row -struct PlaylistItemRow: View { +struct PlaylistItemRow: View { let file: AudioFile let index: Int let isSelected: Bool @@ -12,9 +12,11 @@ struct PlaylistItemRow: View { var numberColumnWidth: CGFloat = 22 /// When non-nil, the row shows a leading grip that is the DRAG SOURCE for reordering /// (`.draggable`). Making only the grip draggable — not the whole row — keeps tap-to-play - /// unambiguous (the row-wide gesture conflict is what killed `.onMove`, FB7367473). Nil - /// (History) shows no handle and is not reorderable. - var dragPayload: QueueDragItem? + /// unambiguous (the row-wide gesture conflict is what killed `.onMove`, FB7367473). Nil shows + /// no handle and is not reorderable. Generic over the payload so the queue reuses this row with + /// a `QueueDragItem` and the playlist detail with a `PlaylistEntryDragItem` (S10.3, architect + /// review) — one row, no fork. + var dragPayload: DragPayload? /// True while a reorder drag is hovering over THIS row (the drop target). Draws an accent /// border so the drop point is visible during the drag (macOS drop-zone affordance). var isDropTarget: Bool = false From 6abb8f517e60766b07061317591f03166f2fe139 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:07:40 +0530 Subject: [PATCH 11/15] fix(s10.3 B): inline rename was undiscoverable + auto-focus self-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder by-ear: a new "New Playlist" couldn't be renamed. - Add double-click-to-rename on a playlist row (Finder/Music convention) — the discoverable gesture alongside the context-menu Rename + the Return key. - Move the rename field's focus into its own .onAppear (reliable once it's in the hierarchy) instead of a deferred @FocusState set from beginRename, which bounced on a freshly-inserted LazyVStack row and let the blur handler self-close the field before the user could type. --- .../AdaptiveSound/UI/Library/LibrarySidebar.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index 592a002..7a4012a 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -156,6 +156,10 @@ struct LibrarySidebar: View { } } .buttonStyle(.plain) + // Double-click to rename (Finder/Music convention) — the discoverable gesture alongside + // the context-menu Rename + the Return key. `.simultaneousGesture` so it coexists with + // the Button's single-click select (plain Buttons in a LazyVStack, not a List — no race). + .simultaneousGesture(TapGesture(count: 2).onEnded { beginRename(playlist) }) .contextMenu { Button("Rename") { beginRename(playlist) } Button("Delete", role: .destructive) { deletePlaylist(playlist) } @@ -183,6 +187,10 @@ struct LibrarySidebar: View { } } .onDisappear { keyboardFocus.isTextEntryFocused = false } + // Focus HERE, in the field's own onAppear — reliable post-insertion (the field is in + // the hierarchy), unlike a @FocusState set from beginRename which bounced on a + // freshly-inserted LazyVStack row and let the blur handler self-close the field. + .onAppear { renameFieldFocused = true } // Capture the draft SYNCHRONOUSLY at submit: a later blur/teardown that clears // `editDraft` must not race the async rename into an empty/stale name. .onSubmit { commitRename(playlist, proposed: editDraft, keepOpenOnConflict: true) } @@ -233,10 +241,8 @@ struct LibrarySidebar: View { editDraft = playlist.name renameError = nil editingPlaylistID = playlist.id - // Defer the focus set: the `TextField` only enters the hierarchy BECAUSE `editingPlaylistID` - // just changed, and focusing a not-yet-inserted field can bounce `@FocusState` back to false - // → the blur handler self-closes the rename. Setting it next runloop targets a live field. - Task { renameFieldFocused = true } + // Focus is set by the rename field's own `.onAppear` (reliable once it's in the hierarchy) — + // NOT here, where the field doesn't exist yet and @FocusState would bounce + self-close. } /// Begin renaming the selected playlist (keyboard Return). `.ignored` unless a playlist row is From d6bcc34b5f05c8c94f26e0f1c7a82addb5b879a3 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:45:10 +0530 Subject: [PATCH 12/15] feat(s10.3 E1): add-to-playlist via context menu + searchable picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The piece that lets you put songs INTO a playlist (US-PLIST-02). Reference-add by track id — NEVER a file move/copy (US-PLIST-04). Drag-to-sidebar is E2. - PlaylistAddDecision (pure, LibraryBrowseKit): dedupe the selection (first-seen order), toast copy. Unit-tested (3/3). Dupes vs the existing playlist are allowed (by design); only the incoming selection is de-duplicated. - PlaylistsModel.addTracks(_:toPlaylist:) + createPlaylist(withTracks:) — append via the DAO's appendEntries (reference-add), reload tree (count badge) + open detail. - AddToPlaylistMenu: context-menu submenu (New Playlist + first-N playlists + an "Add to Playlist…" overflow) reused across library rows; PlaylistPickerSheet: a searchable picker (reuses LibraryFilterField) that scales past the submenu. - Wired into the Songs table context menu (single + multi-select, in sort order); the host owns the sheet (a context menu can't present one). - LibraryBrowseModel.showToast (+ shared raiseToast) for the "Added N songs to …" confirmation on the existing shell toast surface; play actions split to LibraryBrowseModel+Play for file length. strict-gate PASSED; VerifyLibraryStore 121/121; PlaylistAddDecision 3/3. --- .../LibraryBrowseModel+Play.swift | 54 +++++++ .../AdaptiveSound/LibraryBrowseModel.swift | 67 +++------ Sources/AdaptiveSound/PlaylistsModel.swift | 37 +++++ .../AdaptiveSound/UI/Library/SongsTable.swift | 19 +++ .../UI/Playlist/AddToPlaylistMenu.swift | 137 ++++++++++++++++++ .../PlaylistAddDecision.swift | 28 ++++ .../PlaylistAddDecisionTests.swift | 27 ++++ 7 files changed, 319 insertions(+), 50 deletions(-) create mode 100644 Sources/AdaptiveSound/LibraryBrowseModel+Play.swift create mode 100644 Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift create mode 100644 Sources/LibraryBrowseKit/PlaylistAddDecision.swift create mode 100644 Tests/LibraryBrowseKitTests/PlaylistAddDecisionTests.swift diff --git a/Sources/AdaptiveSound/LibraryBrowseModel+Play.swift b/Sources/AdaptiveSound/LibraryBrowseModel+Play.swift new file mode 100644 index 0000000..28c9365 --- /dev/null +++ b/Sources/AdaptiveSound/LibraryBrowseModel+Play.swift @@ -0,0 +1,54 @@ +import Foundation +import LibraryStore + +// MARK: - LibraryBrowseModel + play actions + +/// The browse Play verbs, delegating to `AudioViewModel`'s queue verbs (converting +/// `LibraryTrackDisplay → AudioFile` at this seam). Split into a same-type extension for file +/// length, like `+Facets` / `+History` / `+Sidebar`. +@MainActor +extension LibraryBrowseModel { + /// Play the album now, starting from `startAt` within its (disc/track-ordered) tracks. + func playAlbum(_ albumID: Int64, startAt index: Int = 0) async { + let files = await tracks(inAlbum: albumID).map(AudioFile.init) + guard !files.isEmpty else { return } + audio.playNow(files, startAt: index) + } + + func playAlbumNext(_ albumID: Int64) async { + let files = await tracks(inAlbum: albumID).map(AudioFile.init) + guard !files.isEmpty else { return } + showQueueToast(.playNext, added: audio.playNext(files)) + } + + func appendAlbum(_ albumID: Int64) async { + let files = await tracks(inAlbum: albumID).map(AudioFile.init) + guard !files.isEmpty else { return } + showQueueToast(.addToQueue, added: audio.appendToQueue(files)) + } + + /// Play a specific set of already-loaded display tracks now (album-detail row tap). + func play(_ tracks: [LibraryTrackDisplay], startAt index: Int) { + let files = tracks.map(AudioFile.init) + guard !files.isEmpty else { return } + audio.playNow(files, startAt: index) + } + + func playNext(_ tracks: [LibraryTrackDisplay]) { + guard !tracks.isEmpty else { return } // empty selection → nothing submitted, no toast + showQueueToast(.playNext, added: audio.playNext(tracks.map(AudioFile.init))) + } + + func append(_ tracks: [LibraryTrackDisplay]) { + guard !tracks.isEmpty else { return } // empty selection → nothing submitted, no toast + showQueueToast(.addToQueue, added: audio.appendToQueue(tracks.map(AudioFile.init))) + } + + /// Insert a single track right after the current one and jump to play it NOW (Songs-list + /// double-click / Return / single-row "Play"), preserving the rest of the existing queue. + /// Converts `LibraryTrackDisplay → AudioFile` at this seam (like `play`/`playNext`/`append`) + /// and delegates to `AudioViewModel.playTrackNextNow`. + func playTrackNextNow(_ track: LibraryTrackDisplay) { + audio.playTrackNextNow(AudioFile(track)) + } +} diff --git a/Sources/AdaptiveSound/LibraryBrowseModel.swift b/Sources/AdaptiveSound/LibraryBrowseModel.swift index 9832eb0..0372991 100644 --- a/Sources/AdaptiveSound/LibraryBrowseModel.swift +++ b/Sources/AdaptiveSound/LibraryBrowseModel.swift @@ -392,63 +392,30 @@ final class LibraryBrowseModel { (try? await store?.tracksDisplay(inAlbum: albumID)) ?? [] } - // MARK: - Play actions (delegate to AudioViewModel's queue verbs) - - /// Play the album now, starting from `startAt` within its (disc/track-ordered) tracks. - func playAlbum(_ albumID: Int64, startAt index: Int = 0) async { - let files = await tracks(inAlbum: albumID).map(AudioFile.init) - guard !files.isEmpty else { return } - audio.playNow(files, startAt: index) - } - - func playAlbumNext(_ albumID: Int64) async { - let files = await tracks(inAlbum: albumID).map(AudioFile.init) - guard !files.isEmpty else { return } - showQueueToast(.playNext, added: audio.playNext(files)) - } - - func appendAlbum(_ albumID: Int64) async { - let files = await tracks(inAlbum: albumID).map(AudioFile.init) - guard !files.isEmpty else { return } - showQueueToast(.addToQueue, added: audio.appendToQueue(files)) - } - - /// Play a specific set of already-loaded display tracks now (album-detail row tap). - func play(_ tracks: [LibraryTrackDisplay], startAt index: Int) { - let files = tracks.map(AudioFile.init) - guard !files.isEmpty else { return } - audio.playNow(files, startAt: index) - } - - func playNext(_ tracks: [LibraryTrackDisplay]) { - guard !tracks.isEmpty else { return } // empty selection → nothing submitted, no toast - showQueueToast(.playNext, added: audio.playNext(tracks.map(AudioFile.init))) - } - - func append(_ tracks: [LibraryTrackDisplay]) { - guard !tracks.isEmpty else { return } // empty selection → nothing submitted, no toast - showQueueToast(.addToQueue, added: audio.appendToQueue(tracks.map(AudioFile.init))) - } - - /// Insert a single track right after the current one and jump to play it NOW (Songs-list - /// double-click / Return / single-row "Play"), preserving the rest of the existing queue. - /// Converts `LibraryTrackDisplay → AudioFile` at this seam (like `play`/`playNext`/`append`) - /// and delegates to `AudioViewModel.playTrackNextNow`. - func playTrackNextNow(_ track: LibraryTrackDisplay) { - audio.playTrackNextNow(AudioFile(track)) - } + // Play actions (playAlbum / play / playNext / append / playTrackNextNow) live in + // `LibraryBrowseModel+Play` (a same-type extension, split for file length). // MARK: - Queue toast (S9.5 §10.4) - /// Raise the visibility-gated queue-add toast for a completed add. Silent for Play Now, on the Now - /// Playing tab, or whenever `QueueToastDecision` suppresses it. Coalesces: a new toast replaces the - /// text and resets the ~2 s timer (single cancellable task; the post-sleep guard prevents a - /// superseded task from clearing the newer toast). The token bumps every raise so the view - /// re-announces even an identical string. + /// Raise the visibility-gated queue-add toast for a completed add. Silent for Play Now, on the + /// Now Playing tab, or whenever `QueueToastDecision` suppresses it (see `raiseToast` for the + /// coalesce/auto-dismiss mechanics). func showQueueToast(_ verb: QueueVerb, added: Int) { guard let message = QueueToastDecision.message( verb: verb, addedCount: added, isNowPlayingTab: audio.selectedTab == .nowPlaying ) else { return } + raiseToast(message) + } + + /// Raise a plain-message toast on the shell surface (S10.3 playlist-add "Added N songs to …"). + func showToast(_ message: String) { + raiseToast(message) + } + + /// Shared raise+coalesce+auto-dismiss for the shell toast (queue add / playlist add): replaces + /// the text + resets the ~2 s timer (single cancellable task; post-sleep guard prevents a + /// superseded task clearing the newer toast); the token re-announces even an identical string. + private func raiseToast(_ message: String) { queueToastToken &+= 1 queueToast = QueueToastState(message: message, token: queueToastToken) queueToastDismissTask?.cancel() diff --git a/Sources/AdaptiveSound/PlaylistsModel.swift b/Sources/AdaptiveSound/PlaylistsModel.swift index 5aa2988..4febc6c 100644 --- a/Sources/AdaptiveSound/PlaylistsModel.swift +++ b/Sources/AdaptiveSound/PlaylistsModel.swift @@ -156,6 +156,43 @@ final class PlaylistsModel { } } + // MARK: - Add to playlist (US-PLIST-02 — reference-add, never a file move) + + /// Append tracks (by id — a REFERENCE-add, never a file move/copy) to a playlist, de-duplicating + /// the incoming selection. Reloads the tree (count badge) + the open detail if it's the target. + /// Returns the number appended so the caller can raise a truthful toast. + @discardableResult + func addTracks(_ trackIDs: [Int64], toPlaylist playlistID: Int64) async -> Int { + let ids = PlaylistAddDecision.trackIDsToAdd(trackIDs) + guard let store, !ids.isEmpty else { return 0 } + do { + let entryIDs = try await store.appendEntries(playlistID: playlistID, trackIDs: ids) + await loadTree() + if openPlaylistID == playlistID { await loadDetail(id: playlistID) } + return entryIDs.count + } catch { + treeState = .failed(error.localizedDescription) + return 0 + } + } + + /// Create a new untitled playlist containing `trackIDs` (the "New Playlist" add path) and reload. + /// Returns the new id so the sidebar can select it (+ drop into rename). De-dupes the selection. + @discardableResult + func createPlaylist(withTracks trackIDs: [Int64]) async -> Int64? { + guard let store else { return nil } + let ids = PlaylistAddDecision.trackIDsToAdd(trackIDs) + do { + let id = try await store.createUntitledPlaylist() + if !ids.isEmpty { _ = try await store.appendEntries(playlistID: id, trackIDs: ids) } + await loadTree() + return id + } catch { + treeState = .failed(error.localizedDescription) + return nil + } + } + // MARK: - Detail loading /// Load a playlist's entries in position order, resolving each to its library track. An entry diff --git a/Sources/AdaptiveSound/UI/Library/SongsTable.swift b/Sources/AdaptiveSound/UI/Library/SongsTable.swift index ded469e..a2b1381 100644 --- a/Sources/AdaptiveSound/UI/Library/SongsTable.swift +++ b/Sources/AdaptiveSound/UI/Library/SongsTable.swift @@ -13,9 +13,13 @@ import SwiftUI /// (show/hide/reorder/resize + persistence, §11) via `columnCustomization`. struct SongsTable: View { @Environment(LibraryBrowseModel.self) private var model + @Environment(PlaylistsModel.self) private var playlists @State private var selection = Set() /// The track whose Info popover is open (mirrors the album-detail affordance). @State private var infoTarget: LibraryTrackDisplay? + /// Non-nil while the searchable "Add to Playlist…" picker is open (S10.3) — the host owns the + /// sheet because a context menu can't present one. + @State private var addToPlaylistTarget: AddToPlaylistTarget? /// Per-column show/hide + order + width (§11.4). VIEW-side `@AppStorage` via the NATIVE /// `TableColumnCustomization` overload (macOS 14+ — NO Codable/JSON bridge): survives the /// tab-`switch` teardown AND persists across launches by re-reading `UserDefaults`; an @@ -71,6 +75,10 @@ struct SongsTable: View { .onKeyPress(.return) { playSingleTrack(startingAt: selection) ? .handled : .ignored } + // Host the searchable "Add to Playlist…" picker (the context-menu submenu can't own it). + .sheet(item: $addToPlaylistTarget) { target in + PlaylistPickerSheet(trackIDs: target.trackIDs) + } // Header click → re-map to a `TrackSort` + DAO re-read. Two-param form (the one-param is // deprecated); no `initial:` — firing on the seed would clobber the composite anchor, and // `onChange` correctly does not fire on the initial value (§3.1/§6). @@ -172,15 +180,26 @@ struct SongsTable: View { Button("Play Next") { model.playNext(tracks) } Button("Add to Queue") { model.append(tracks) } Divider() + addToPlaylistMenu(trackIDs: tracks.map(\.id)) + Divider() Button("Info", systemImage: "info.circle") { infoTarget = tracks.first } } else if let track = SongsRowResolver.primaryRow(in: model.visibleSongs, selection: ids) { Button("Play") { model.playTrackNextNow(track) } // single track: insert next + jump Button("Play Next") { model.playNext([track]) } Button("Add to Queue") { model.append([track]) } Divider() + addToPlaylistMenu(trackIDs: [track.id]) + Divider() Button("Info", systemImage: "info.circle") { infoTarget = track } } } + + /// The reference-add "Add to Playlist" submenu (S10.3); overflow opens the searchable picker. + private func addToPlaylistMenu(trackIDs: [Int64]) -> some View { + AddToPlaylistMenu(trackIDs: trackIDs) { + addToPlaylistTarget = AddToPlaylistTarget(trackIDs: trackIDs) + } + } } // MARK: - Columns (split into sub-builders so the 15-column `body` type-checks) diff --git a/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift new file mode 100644 index 0000000..ae5f26d --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift @@ -0,0 +1,137 @@ +import LibraryBrowseKit +import LibraryStore +import SwiftUI + +// MARK: - Add to Playlist (S10.3 US-PLIST-02 — context-menu submenu + searchable picker) + +/// A reusable "Add to Playlist" submenu for a library-row context menu. Reference-adds the given +/// `trackIDs` (by id — NEVER a file move) via `PlaylistsModel`, with a confirmation toast. Inline: +/// New Playlist + the first few playlists; overflow routes to the searchable `PlaylistPickerSheet` +/// through `onChooseMore` (a context menu can't own a sheet — the host presents it). +struct AddToPlaylistMenu: View { + let trackIDs: [Int64] + /// Called when the user picks "Add to Playlist…" — the host opens `PlaylistPickerSheet`. + let onChooseMore: () -> Void + @Environment(PlaylistsModel.self) private var playlists + @Environment(LibraryBrowseModel.self) private var library + + /// How many playlists to show inline before deferring to the searchable sheet. + private let inlineLimit = 6 + + var body: some View { + Menu("Add to Playlist") { + Button("New Playlist") { Task { await createAndAdd() } } + if !playlists.playlists.isEmpty { + Divider() + ForEach(playlists.playlists.prefix(inlineLimit)) { playlist in + Button(playlist.name) { Task { await add(to: playlist) } } + } + if playlists.playlists.count > inlineLimit { + Divider() + Button("Add to Playlist…") { onChooseMore() } + } + } + } + .disabled(trackIDs.isEmpty) + } + + private func add(to playlist: Playlist) async { + let added = await playlists.addTracks(trackIDs, toPlaylist: playlist.id) + if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: playlist.name) { + library.showToast(message) + } + } + + private func createAndAdd() async { + guard let id = await playlists.createPlaylist(withTracks: trackIDs) else { return } + let name = playlists.playlists.first { $0.id == id }?.name ?? "New Playlist" + let added = PlaylistAddDecision.trackIDsToAdd(trackIDs).count + if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: name) { + library.showToast(message) + } + } +} + +// MARK: - Picker sheet (scales past the inline submenu) + +/// The `Identifiable` payload the host binds a `.sheet(item:)` to — the tracks awaiting a playlist +/// choice (UUID identity so re-triggering with the same tracks re-presents). +struct AddToPlaylistTarget: Identifiable { + let id = UUID() + let trackIDs: [Int64] +} + +/// A searchable playlist picker (US-PLIST-02 "scales to hundreds"): filter with the shared +/// `LibraryFilterField`, tap a playlist to reference-add + dismiss. New Playlist is here too. +struct PlaylistPickerSheet: View { + let trackIDs: [Int64] + @Environment(PlaylistsModel.self) private var playlists + @Environment(LibraryBrowseModel.self) private var library + @Environment(\.dismiss) private var dismiss + @State private var filter = "" + + private var filtered: [Playlist] { + let query = filter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return playlists.playlists } + return playlists.playlists.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Add to Playlist").font(DesignSystem.Font.sectionTitle) + Spacer() + Button("Done") { dismiss() } + } + .padding(DesignSystem.Spacing.medium) + + LibraryFilterField(query: $filter, placeholder: "Filter playlists") + .padding(.horizontal, DesignSystem.Spacing.medium) + + Button { + Task { await createAndAdd() } + } label: { + Label("New Playlist", systemImage: "plus").frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(DesignSystem.Spacing.medium) + + Divider() + + List(filtered) { playlist in + Button { + Task { await add(to: playlist); dismiss() } + } label: { + HStack(spacing: DesignSystem.Spacing.small) { + Image(systemName: "music.note.list") + Text(playlist.name).lineLimit(1) + Spacer() + Text(playlist.entryCount.formatted(.number)) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 380, height: 460) + } + + private func add(to playlist: Playlist) async { + let added = await playlists.addTracks(trackIDs, toPlaylist: playlist.id) + if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: playlist.name) { + library.showToast(message) + } + } + + private func createAndAdd() async { + guard let id = await playlists.createPlaylist(withTracks: trackIDs) else { return } + let name = playlists.playlists.first { $0.id == id }?.name ?? "New Playlist" + let added = PlaylistAddDecision.trackIDsToAdd(trackIDs).count + if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: name) { + library.showToast(message) + } + dismiss() + } +} diff --git a/Sources/LibraryBrowseKit/PlaylistAddDecision.swift b/Sources/LibraryBrowseKit/PlaylistAddDecision.swift new file mode 100644 index 0000000..c1fd83c --- /dev/null +++ b/Sources/LibraryBrowseKit/PlaylistAddDecision.swift @@ -0,0 +1,28 @@ +// MARK: - PlaylistAddDecision (S10.3 — add-to-playlist: order, dedupe, toast; pure + testable) + +/// Pure decisions for "add these tracks to a playlist" (US-PLIST-02). Extracted so the multi-select +/// order/dedupe + the confirmation copy are unit-testable, independent of SwiftUI + the store. +/// +/// Membership is a REFERENCE-ADD by track id — never a file move/copy (US-PLIST-04). Duplicates +/// against the EXISTING playlist are allowed (a playlist may hold the same track twice, by design); +/// only the incoming SELECTION is de-duplicated (selecting the same row twice shouldn't add it +/// twice), preserving first-seen order so the added block matches the on-screen order. +public enum PlaylistAddDecision { + /// The track ids to append: the selection de-duplicated, first-seen order preserved. Empty in → + /// empty out (a no-op add). + public static func trackIDsToAdd(_ selected: [Int64]) -> [Int64] { + var seen = Set() + var ordered: [Int64] = [] + for id in selected where seen.insert(id).inserted { + ordered.append(id) + } + return ordered + } + + /// The confirmation-toast copy for adding `count` tracks to `playlistName`; nil when nothing was + /// added (no toast for a no-op). + public static func toastMessage(added count: Int, playlistName: String) -> String? { + guard count > 0 else { return nil } + return "Added \(count) \(count == 1 ? "song" : "songs") to “\(playlistName)”" + } +} diff --git a/Tests/LibraryBrowseKitTests/PlaylistAddDecisionTests.swift b/Tests/LibraryBrowseKitTests/PlaylistAddDecisionTests.swift new file mode 100644 index 0000000..e1b4577 --- /dev/null +++ b/Tests/LibraryBrowseKitTests/PlaylistAddDecisionTests.swift @@ -0,0 +1,27 @@ +import LibraryBrowseKit +import Testing + +// MARK: - PlaylistAddDecision (S10.3 — order / dedupe / toast) + +@Suite("PlaylistAddDecision — order, dedupe, toast") +struct PlaylistAddDecisionTests { + /// ADD-1 — the selection is de-duplicated, first-seen order preserved. + @Test("dedupe selection, keep first-seen order") + func dedupePreservesOrder() { + #expect(PlaylistAddDecision.trackIDsToAdd([3, 1, 3, 2, 1]) == [3, 1, 2]) + } + + /// ADD-2 — empty selection → empty (no-op add). + @Test("empty → empty") + func empty() { + #expect(PlaylistAddDecision.trackIDsToAdd([]).isEmpty) + } + + /// ADD-3 — toast copy: pluralization + nil for a no-op. + @Test("toast copy + no-op silence") + func toast() { + #expect(PlaylistAddDecision.toastMessage(added: 1, playlistName: "Rock") == "Added 1 song to “Rock”") + #expect(PlaylistAddDecision.toastMessage(added: 3, playlistName: "Rock") == "Added 3 songs to “Rock”") + #expect(PlaylistAddDecision.toastMessage(added: 0, playlistName: "Rock") == nil) + } +} From 0b711c0c9c1fbfcac9d22b82e968ac43d5c5639d Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:54:10 +0530 Subject: [PATCH 13/15] fix(s10.3): rename key-hijack + codebase focus/keyboard audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder-reported: playlist rename was impossible. Root-caused from a runtime log trace, then reviewed by swiftui-pro (fix + codebase-wide) + architect/Fool against the SwiftUI focus/keyboard programming model. The bug: the sidebar ScrollView (.focusable + .onKeyPress) sat over a focused rename TextField, so Return LEAKED UP to the ancestor's .onKeyPress(.return) (re-invoking beginRename → wiping the typed draft) instead of the field's onSubmit. Fix (the correct, generalizable template — an ancestor onKeyPress fires because the field is its hierarchical descendant regardless of which @FocusState is active, so the gate is the fix, not an enum): - Sidebar .onKeyPress(.up/.down/.return) return .ignored while editingPlaylistID != nil. - beginRename re-entry guard + yields sidebarFocused; focus set in the field's own .onAppear; restore list focus on the KEYBOARD close paths only (finishRename). Codebase focus audit outcome — we broadly ADHERE; the collision was ISOLATED to LibrarySidebar (every other focusable-list view has one @FocusState + no editable descendant). Fixes applied from the audit: - Extract .suppressesTransportSpace(while:) — replaces the copy-pasted .focused + onChange + onDisappear Space-gate ritual in all 4 text fields (LibraryFilterField, SongsHeader, SaveCustomPresetView, sidebar rename), so a field can't half-wire it and silently re-break space-typing (S4 SW1). - PlaylistDetailView: post-delete neighbor from the resolved-row set + .defaultFocus. - Nits: removed the dead .onKeyPress(.space) in the queue (Controls-menu equivalent preempts it); .ignored on empty-selection Return in the facet lists; auto-focus the Save-Preset + playlist-picker fields (LibraryFilterField focusesOnAppear). Deferred to Chunk D (documented, design §8): a single `enum SidebarEdit` owner + the thin keyboardListNavigation scaffold, so folder-rename can't re-detonate the hijack one field over. Founder by-ear PASSED (rename commits + keeps kb-nav; space-in-filter inserts). strict-gate PASSED. --- .../UI/EQ/SaveCustomPresetView.swift | 12 ++-- .../UI/Library/AlbumDetailView.swift | 3 +- .../UI/Library/FacetTrackListView.swift | 4 +- .../UI/Library/LibraryFilterField.swift | 13 ++-- .../UI/Library/LibrarySidebar.swift | 63 +++++++++++++------ .../UI/Library/SongsHeader.swift | 9 +-- .../UI/Playlist/AddToPlaylistMenu.swift | 2 +- .../UI/Playlist/PlaylistDetailView.swift | 6 +- .../UI/Playlist/PlaylistView.swift | 4 +- .../UI/Shell/KeyboardTransportFocus.swift | 13 ++-- .../UI/Shell/TransportSpaceSuppressing.swift | 31 +++++++++ docs/sprints/s10-3-playlists-ux-design.md | 1 + 12 files changed, 108 insertions(+), 53 deletions(-) create mode 100644 Sources/AdaptiveSound/UI/Shell/TransportSpaceSuppressing.swift diff --git a/Sources/AdaptiveSound/UI/EQ/SaveCustomPresetView.swift b/Sources/AdaptiveSound/UI/EQ/SaveCustomPresetView.swift index c3458ea..9cb475a 100644 --- a/Sources/AdaptiveSound/UI/EQ/SaveCustomPresetView.swift +++ b/Sources/AdaptiveSound/UI/EQ/SaveCustomPresetView.swift @@ -13,9 +13,6 @@ struct SaveCustomPresetView: View { @Binding var isPresented: Bool @State private var presetName: String = "" - /// Suppresses the global Space accelerator while the name field is focused (S4 SW1) — else a - /// space typed into the preset name would toggle playback instead of inserting a space. - @Environment(KeyboardTransportFocus.self) private var keyboardFocus @FocusState private var nameFocused: Bool private var canSave: Bool { @@ -29,11 +26,10 @@ struct SaveCustomPresetView: View { TextField("Preset name", text: $presetName) .textFieldStyle(.roundedBorder) - .focused($nameFocused) - .onChange(of: nameFocused) { _, focused in - keyboardFocus.isTextEntryFocused = focused - } - .onDisappear { keyboardFocus.isTextEntryFocused = false } + // Focus + the transport-Space gate (S4 SW1) in one place; auto-focus so the sheet + // is type-ready on appear. + .suppressesTransportSpace(while: $nameFocused) + .onAppear { nameFocused = true } .onSubmit { commitSave() } diff --git a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift index 23993f7..5fec24b 100644 --- a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift +++ b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift @@ -130,7 +130,8 @@ struct AlbumDetailView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - .onKeyPress(.return) { playSelected(); return .handled } // keyboard play (S4 A-M4) + // Keyboard play (S4 A-M4); `.ignored` on an empty selection so Return bubbles (focus-audit nit). + .onKeyPress(.return) { selection.isEmpty ? .ignored : { playSelected(); return .handled }() } } /// Play the album starting at the selected row — the keyboard Return path (double-click is diff --git a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift index 50dd819..250ae25 100644 --- a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift +++ b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift @@ -126,7 +126,7 @@ struct FacetTrackListView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - .onKeyPress(.return) { playSelected(); return .handled } + .onKeyPress(.return) { selection.isEmpty ? .ignored : { playSelected(); return .handled }() } } private var groupedList: some View { @@ -143,7 +143,7 @@ struct FacetTrackListView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - .onKeyPress(.return) { playSelected(); return .handled } + .onKeyPress(.return) { selection.isEmpty ? .ignored : { playSelected(); return .handled }() } } private func sectionHeader(_ section: FacetAlbumSection) -> some View { diff --git a/Sources/AdaptiveSound/UI/Library/LibraryFilterField.swift b/Sources/AdaptiveSound/UI/Library/LibraryFilterField.swift index 8c8f610..22ef598 100644 --- a/Sources/AdaptiveSound/UI/Library/LibraryFilterField.swift +++ b/Sources/AdaptiveSound/UI/Library/LibraryFilterField.swift @@ -9,8 +9,9 @@ import SwiftUI struct LibraryFilterField: View { @Binding var query: String let placeholder: String - /// Suppresses the global Space accelerator while this filter field is focused (S4 SW1). - @Environment(KeyboardTransportFocus.self) private var keyboardFocus + /// Focus the field when it appears (e.g. a picker sheet that should be type-ready). Default off: + /// the in-tab filters focus on ⌘F / click, not on every tab visit. + var focusesOnAppear = false @FocusState private var focused: Bool var body: some View { @@ -21,11 +22,9 @@ struct LibraryFilterField: View { .textFieldStyle(.plain) .font(DesignSystem.Font.body) .foregroundStyle(DesignSystem.Color.label) - .focused($focused) - .onChange(of: focused) { _, isFocused in - keyboardFocus.isTextEntryFocused = isFocused - } - .onDisappear { keyboardFocus.isTextEntryFocused = false } + // Focus + the transport-Space gate (S4 SW1) in one place. + .suppressesTransportSpace(while: $focused) + .onAppear { if focusesOnAppear { focused = true } } .onExitCommand { // macOS Cancel (Escape): clear then defocus query = "" focused = false diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index 7a4012a..ea9e053 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -59,11 +59,15 @@ struct LibrarySidebar: View { .focused($sidebarFocused) .defaultFocus($sidebarFocused, true) .focusEffectDisabled() - .onKeyPress(.upArrow) { moveSelection(by: -1) } - .onKeyPress(.downArrow) { moveSelection(by: 1) } + // ↑/↓/Return stand down WHILE a rename field is open — otherwise this ScrollView (still + // in the focus chain) HIJACKS the keys from the focused TextField: Return hit + // `renameSelectedPlaylist` (re-entrant `beginRename` → wiped the typed draft) instead of + // the field's `onSubmit`, and arrows moved the sidebar selection instead of the cursor. + .onKeyPress(.upArrow) { editingPlaylistID == nil ? moveSelection(by: -1) : .ignored } + .onKeyPress(.downArrow) { editingPlaylistID == nil ? moveSelection(by: 1) : .ignored } // Return renames the selected playlist (Finder/Music convention + keyboard discoverability // for an action otherwise only in the right-click menu). Categories ignore it (bubbles). - .onKeyPress(.return) { renameSelectedPlaylist() } + .onKeyPress(.return) { editingPlaylistID == nil ? renameSelectedPlaylist() : .ignored } } // A sidebar material so the column reads as a source list now that `.listStyle(.sidebar)` is // gone (the plain ScrollView doesn't imply it). @@ -172,21 +176,18 @@ struct LibrarySidebar: View { TextField("Playlist name", text: $editDraft) .textFieldStyle(.plain) .font(DesignSystem.Font.body) - .focused($renameFieldFocused) - // Suppress the global Space accelerator while editing (S4 SW1) — the same inline - // trio `LibraryFilterField`/`SongsHeader` use; Chunk E extracts it to a modifier. + // Applies `.focused($renameFieldFocused)` AND the transport-Space gate in one place. + .suppressesTransportSpace(while: $renameFieldFocused) // Click-away COMMITS (Finder/Music convention — silent revert is surprising data // loss). Guarded on `wasFocused` so the deferred-focus arrival (false→true) can't // self-commit, and on `editingPlaylistID` so a post-teardown blur is a no-op. // Escape (`.onExitCommand`) is the sole cancel and niles `editingPlaylistID` first, // so a blur it triggers is guarded out (no commit-on-Escape). .onChange(of: renameFieldFocused) { wasFocused, isFocused in - keyboardFocus.isTextEntryFocused = isFocused if wasFocused, !isFocused, editingPlaylistID == playlist.id { commitRename(playlist, proposed: editDraft, keepOpenOnConflict: false) } } - .onDisappear { keyboardFocus.isTextEntryFocused = false } // Focus HERE, in the field's own onAppear — reliable post-insertion (the field is in // the hierarchy), unlike a @FocusState set from beginRename which bounced on a // freshly-inserted LazyVStack row and let the blur handler self-close the field. @@ -194,7 +195,10 @@ struct LibrarySidebar: View { // Capture the draft SYNCHRONOUSLY at submit: a later blur/teardown that clears // `editDraft` must not race the async rename into an empty/stale name. .onSubmit { commitRename(playlist, proposed: editDraft, keepOpenOnConflict: true) } - .onExitCommand { cancelRename() } + .onExitCommand { + cancelRename() + sidebarFocused = true // keyboard close → keep ↑/↓/Return alive (focus-audit MAJOR) + } .padding(.horizontal, DesignSystem.Spacing.small) .padding(.vertical, 5) if let renameError { @@ -238,11 +242,17 @@ struct LibrarySidebar: View { } private func beginRename(_ playlist: Playlist) { + // Re-entry guard: a rename already in progress for THIS playlist must NOT restart — that would + // reset `editDraft` and wipe the user's in-progress typing (observed: a hijacked Return + // re-invoked this and clobbered the name; see the editing-gated `.onKeyPress` handlers). + guard editingPlaylistID != playlist.id else { return } editDraft = playlist.name renameError = nil editingPlaylistID = playlist.id - // Focus is set by the rename field's own `.onAppear` (reliable once it's in the hierarchy) — - // NOT here, where the field doesn't exist yet and @FocusState would bounce + self-close. + // Yield the sidebar's key focus so the rename TextField (focused in its own `.onAppear`) owns + // Return/arrows — otherwise the ScrollView's `.onKeyPress` handlers intercept them. Restored + // on the keyboard close paths (`commitRename`/`onExitCommand`) so ↑/↓ stay alive after. + sidebarFocused = false } /// Begin renaming the selected playlist (keyboard Return). `.ignored` unless a playlist row is @@ -273,23 +283,25 @@ struct LibrarySidebar: View { private func commitRename(_ playlist: Playlist, proposed: String, keepOpenOnConflict: Bool) { guard editingPlaylistID == playlist.id else { return } let name = proposed.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, name != playlist.name else { cancelRename(); return } + // `keepOpenOnConflict` distinguishes the KEYBOARD paths (Return) from click-away, so it + // doubles as "restore list focus on close" — a keyboard close keeps ↑/↓/Return alive. + guard !name.isEmpty, name != playlist.name else { + finishRename(restoreListFocus: keepOpenOnConflict) + return + } Task { do { try await playlists.renamePlaylist(id: playlist.id, to: name) // The user may have begun renaming ANOTHER row during the await — only close if THIS // playlist is still the one being edited (QA break-it #5). - if editingPlaylistID == playlist.id { cancelRename() } + if editingPlaylistID == playlist.id { finishRename(restoreListFocus: keepOpenOnConflict) } } catch let conflict as PlaylistNameConflict where keepOpenOnConflict { - renameError = "“\(conflict.name)” already exists." - renameFieldFocused = true + showRenameError("“\(conflict.name)” already exists.") } catch PlaylistMutationError.invalidName where keepOpenOnConflict { - renameError = "That name can’t be used." // reserved ("current") — empty is pre-guarded - renameFieldFocused = true + showRenameError("That name can’t be used.") // reserved ("current") — empty is pre-guarded } catch { if keepOpenOnConflict { - renameError = "Couldn’t rename this playlist." - renameFieldFocused = true + showRenameError("Couldn’t rename this playlist.") } else { cancelRename() // leaving the field: revert rather than trap on the error } @@ -297,6 +309,19 @@ struct LibrarySidebar: View { } } + /// Close the rename field; on a KEYBOARD close (Return/Escape) restore list focus so ↑/↓ stay + /// alive — NOT on click-away, where the user intentionally moved focus elsewhere (focus-audit). + private func finishRename(restoreListFocus: Bool) { + cancelRename() + if restoreListFocus { sidebarFocused = true } + } + + /// Surface an inline rename error + keep the field open/focused for a retry. + private func showRenameError(_ message: String) { + renameError = message + renameFieldFocused = true + } + private func cancelRename() { editingPlaylistID = nil editDraft = "" diff --git a/Sources/AdaptiveSound/UI/Library/SongsHeader.swift b/Sources/AdaptiveSound/UI/Library/SongsHeader.swift index 94e0bde..388b0c9 100644 --- a/Sources/AdaptiveSound/UI/Library/SongsHeader.swift +++ b/Sources/AdaptiveSound/UI/Library/SongsHeader.swift @@ -9,8 +9,6 @@ import SwiftUI /// re-sums the total. struct SongsHeader: View { @Environment(LibraryBrowseModel.self) private var model - /// Suppresses the global Space accelerator while this filter field is focused (S4 SW1). - @Environment(KeyboardTransportFocus.self) private var keyboardFocus /// Drives ⌘F focus + Escape defocus of the filter field (design §3.2/§8). @FocusState private var filterFocused: Bool /// The SAME per-column state the table binds (identical `@AppStorage` key → no drift with the @@ -53,11 +51,8 @@ struct SongsHeader: View { .textFieldStyle(.plain) .font(DesignSystem.Font.body) .foregroundStyle(DesignSystem.Color.label) - .focused($filterFocused) - .onChange(of: filterFocused) { _, focused in - keyboardFocus.isTextEntryFocused = focused - } - .onDisappear { keyboardFocus.isTextEntryFocused = false } + // Focus + the transport-Space gate (S4 SW1) in one place. + .suppressesTransportSpace(while: $filterFocused) .onExitCommand { query.wrappedValue = "" filterFocused = false diff --git a/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift index ae5f26d..58f7211 100644 --- a/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift +++ b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift @@ -85,7 +85,7 @@ struct PlaylistPickerSheet: View { } .padding(DesignSystem.Spacing.medium) - LibraryFilterField(query: $filter, placeholder: "Filter playlists") + LibraryFilterField(query: $filter, placeholder: "Filter playlists", focusesOnAppear: true) .padding(.horizontal, DesignSystem.Spacing.medium) Button { diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift index 5e22ac7..3d7e611 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift @@ -126,6 +126,7 @@ struct PlaylistDetailView: View { } .focusable() .focused($listFocused) + .defaultFocus($listFocused, true) .focusEffectDisabled() .frame(maxWidth: .infinity, maxHeight: .infinity) .onKeyPress(.upArrow) { moveSelection(by: -1) } @@ -274,8 +275,9 @@ struct PlaylistDetailView: View { private func removeSelected() -> KeyPress.Result { guard let id = selectedEntryID else { return .ignored } // Pre-select the neighbor (next, else previous) so selection lands there — not back at the - // top — once the async remove + reload lands (the neighbor id survives the reload). - let ids = model.detail.map(\.id) + // top — once the async remove + reload lands. Use the SAME resolved-row set `moveSelection` + // traverses, so the neighbor is a selectable row (not an unavailable placeholder). + let ids = model.detail.filter { $0.display != nil }.map(\.id) if let index = ids.firstIndex(of: id) { selectedEntryID = index + 1 < ids.count ? ids[index + 1] : (index - 1 >= 0 ? ids[index - 1] : nil) diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift index a3aeda0..134c6a3 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift @@ -211,7 +211,9 @@ private struct PlaylistItemList: View { .onKeyPress(.upArrow) { moveSelection(by: -1) } .onKeyPress(.downArrow) { moveSelection(by: 1) } .onKeyPress(.return) { togglePlayIfSelected() } - .onKeyPress(.space) { togglePlayIfSelected() } + // No `.onKeyPress(.space)`: the Controls-menu Space key-equivalent is matched first + // (disabled only while a text field is focused), so this handler was dead — Return + // already covers keyboard toggle here (focus-audit nit). .onKeyPress(.delete) { guard let index = viewModel.selectedTrackIndex else { return .ignored } viewModel.removeTrack(at: index) diff --git a/Sources/AdaptiveSound/UI/Shell/KeyboardTransportFocus.swift b/Sources/AdaptiveSound/UI/Shell/KeyboardTransportFocus.swift index e093f3a..3d8f041 100644 --- a/Sources/AdaptiveSound/UI/Shell/KeyboardTransportFocus.swift +++ b/Sources/AdaptiveSound/UI/Shell/KeyboardTransportFocus.swift @@ -18,13 +18,16 @@ import SwiftUI /// ## Why a single flag is safe /// These fields are never focused simultaneously — the Library filter fields live in mutually /// exclusive tabs/sections and the Save-Preset field is a modal sheet — so there is never a second -/// focused field whose state a shared flag could clobber. Each field mirrors its `@FocusState` into -/// this flag on change AND clears it on `.onDisappear`, so a field that is torn down while focused -/// (a tab switch destroys the Library subtree) still releases the gate. +/// focused field whose state a shared flag could clobber. +/// +/// ## Wiring +/// Fields wire this via the `.suppressesTransportSpace(while:)` modifier (see +/// `TransportSpaceSuppressing`), which owns the `.focused` + `.onChange` + `.onDisappear` trio in one +/// place so a field can't half-wire it. (A later hardening could make this a `Set` or derive +/// it from the AppKit first responder to also close the focus-handoff transposition race — focus-audit.) @MainActor @Observable final class KeyboardTransportFocus { - /// True while a text-entry field holds keyboard focus. Set by each field's `.onChange(of:)` - /// and cleared on `.onDisappear`. + /// True while a text-entry field holds keyboard focus. Driven by `.suppressesTransportSpace(while:)`. var isTextEntryFocused = false } diff --git a/Sources/AdaptiveSound/UI/Shell/TransportSpaceSuppressing.swift b/Sources/AdaptiveSound/UI/Shell/TransportSpaceSuppressing.swift new file mode 100644 index 0000000..a05e0ad --- /dev/null +++ b/Sources/AdaptiveSound/UI/Shell/TransportSpaceSuppressing.swift @@ -0,0 +1,31 @@ +import SwiftUI + +// MARK: - suppressesTransportSpace (S10.3 focus-audit — the one-place gate wiring) + +/// Binds a text field's `@FocusState` AND wires the global transport-Space gate in ONE place: +/// applies `.focused`, mirrors focus into `KeyboardTransportFocus.isTextEntryFocused`, and clears +/// it on teardown. Previously every field hand-wired the `.focused` + `.onChange` + `.onDisappear` +/// trio; a field that forgot either half silently re-broke space-typing (S4 SW1) or left the gate +/// stuck on. As a single modifier the half-wired state is unrepresentable: a field either applies +/// it (atomic + correct) or doesn't. (Focus-audit MAJOR-1; the fix the sidebar comment promised.) +private struct TransportSpaceSuppressing: ViewModifier { + @FocusState.Binding var focused: Bool + @Environment(KeyboardTransportFocus.self) private var gate + + func body(content: Content) -> some View { + content + .focused($focused) + .onChange(of: focused) { _, isFocused in gate.isTextEntryFocused = isFocused } + .onDisappear { gate.isTextEntryFocused = false } + } +} + +extension View { + /// Focus this text field via `focused` AND suppress the global Space play/pause accelerator while + /// it holds focus (so a typed space inserts a space instead of toggling playback). Replaces the + /// per-field `.focused` + `.onChange` + `.onDisappear` gate trio. Apply INSTEAD of a separate + /// `.focused($…)`; additional `.onChange(of:)` (e.g. an inline-rename blur-commit) still compose. + func suppressesTransportSpace(while focused: FocusState.Binding) -> some View { + modifier(TransportSpaceSuppressing(focused: focused)) + } +} diff --git a/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md index a6031e5..d32ea9c 100644 --- a/docs/sprints/s10-3-playlists-ux-design.md +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -81,6 +81,7 @@ Chunks A+B passed swift-expert + swiftui-pro reviews and a qa-expert + architect - **Chunk C:** generalize `PlaylistItemRow`'s drag payload (hardwired to `QueueDragItem`; playlist reorder needs `PlaylistEntryDragItem`) + model playlist "now playing" as an explicit `activeEntryID` (not the queue-cursor `isNowPlaying`). - **Chunk C/E:** a playlist-internal mutation (add/remove/reorder) MUST call `loadTree()` — entry-count badges don't ride `libraryRevision`. - **Before Chunk D:** collapse the dual source of truth for "which playlist is open" (`LibraryBrowseModel.path.last` vs `PlaylistsModel.openPlaylistID`) so a folder cascade-delete can't orphan a ghost detail (D also clears `openPlaylistID` for a deleted subtree via the snapshot ids). The `closeDetail` epoch bump mitigates the model-state inconsistency in the interim. +- **In Chunk D (focus-audit, 2026-07-16):** folder rename adds a SECOND inline editor to `LibrarySidebar`. Introduce a single edit-mode owner — `enum SidebarEdit { case none, playlist(Int64), folder(Int64) }` (replacing the lone `editingPlaylistID`) — and gate EVERY sidebar `.onKeyPress` on `case .none`. Otherwise the seed rename bug re-detonates one field over: a folder-rename field whose ancestor key-guards still read only `editingPlaylistID == nil` lets Return/arrows hijack again. At that 4th copy of the pattern, also extract the thin `keyboardListNavigation(focus:onMove:onReturn:onDelete:isEditing:)` scaffold modifier (with the editing-stand-down baked in) shared by the queue / playlist-detail / sidebar lists. (Selection models stay per-view: index vs id vs enum.) - **Chunk F:** replace `PlaylistDetailEntry.display == nil` with an explicit `resolution: .ok | .missing` — a moved file keeps its id (resolves) and a deleted track's entry is cascade-dropped, so nil-display is near-vestigial; the real dead case is a LOOSE file gone from disk. - **Watch:** the `LoadState`/epoch pattern is duplicated across `LibraryBrowseModel`/`PlaylistsModel` — extract at the 3rd copy (smart playlists). From cd4179c10359c318e97b53b2d0050ce8f819a973 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:06:09 +0530 Subject: [PATCH 14/15] =?UTF-8?q?feat(s10.3=20E2):=20drag=20songs=20?= =?UTF-8?q?=E2=86=92=20sidebar=20playlist=20(reference-add,=20never=20a=20?= =?UTF-8?q?file=20move)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the add-to-playlist story (US-PLIST-03) on top of E1's context menu. - LibraryTrackDragItem (track-id payload), draggable from the Album/Artist/Genre detail List rows — a List row's drag SOURCE works (only its .dropDestination doesn't, which is why the sidebar is a LazyVStack). - Sidebar playlist rows are .dropDestination(for: LibraryTrackDragItem.self) with a drop-ring highlight → PlaylistDropRouter → addTracks + confirmation toast. A file-URL/audio drag can't match the typed payload, so it never reaches the handler. - PlaylistDropRouter (pure, LibraryBrowseKit): PlaylistDropOutcome has EXACTLY ONE case (.addTracks) — "a drop can never touch the filesystem" is a type-level guarantee (US-PLIST-04), not a convention. Unit-tested (2/2). - strict-gate: new guard greps the playlist add/drop path (sidebar + model + router + playlist DAO) for moveItem/copyItem and fails if present. Folded-in cleanup (SME focus-audit): extracted LibrarySidebar+Rename.swift (type-body length); the .suppressesTransportSpace modifier now owns the transport-Space gate, so the sidebar dropped its manual keyboardFocus wiring. strict-gate PASSED (incl. the new drop-path guard); VerifyLibraryStore 121/121; PlaylistDropRouter 2/2. --- .../Models/LibraryTrackDragItem.swift | 17 +++ .../UI/Library/AlbumDetailView.swift | 3 + .../UI/Library/FacetTrackListView.swift | 3 + .../UI/Library/LibrarySidebar+Rename.swift | 93 +++++++++++++ .../UI/Library/LibrarySidebar.swift | 131 ++++++------------ .../LibraryBrowseKit/PlaylistDropRouter.swift | 19 +++ .../PlaylistDropRouterTests.swift | 20 +++ scripts/strict-gate.sh | 19 +++ 8 files changed, 214 insertions(+), 91 deletions(-) create mode 100644 Sources/AdaptiveSound/Models/LibraryTrackDragItem.swift create mode 100644 Sources/AdaptiveSound/UI/Library/LibrarySidebar+Rename.swift create mode 100644 Sources/LibraryBrowseKit/PlaylistDropRouter.swift create mode 100644 Tests/LibraryBrowseKitTests/PlaylistDropRouterTests.swift diff --git a/Sources/AdaptiveSound/Models/LibraryTrackDragItem.swift b/Sources/AdaptiveSound/Models/LibraryTrackDragItem.swift new file mode 100644 index 0000000..7e53f53 --- /dev/null +++ b/Sources/AdaptiveSound/Models/LibraryTrackDragItem.swift @@ -0,0 +1,17 @@ +import CoreTransferable + +/// Transferable payload for dragging a LIBRARY track onto a playlist (S10.3 US-PLIST-03/04). Carries +/// only the track's stable `tracks.id` — a reference-ADD by id, which by construction can NEVER move +/// or copy a file (US-PLIST-04). Same plain-text `ProxyRepresentation` rationale as the other drag +/// items (a custom UTType not declared in Info.plist silently fails drop-type matching); a foreign +/// text drop imports to a nonexistent id that the DAO's FK rejects, so it safely no-ops. +struct LibraryTrackDragItem: Transferable { + let trackID: Int64 + + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation( + exporting: { (item: LibraryTrackDragItem) in String(item.trackID) }, + importing: { (string: String) in LibraryTrackDragItem(trackID: Int64(string) ?? -1) } + ) + } +} diff --git a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift index 5fec24b..db2bdd6 100644 --- a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift +++ b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift @@ -99,6 +99,9 @@ struct AlbumDetailView: View { List(selection: $selection) { ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in TrackRow(track: track, leadingNumber: track.trackNo ?? (index + 1)) + // Drag a track onto a sidebar playlist (US-PLIST-03) — reference-add by id, never + // a file move. A List row's drag SOURCE works (only its .dropDestination doesn't). + .draggable(LibraryTrackDragItem(trackID: track.id)) // `.simultaneousGesture` (not `.onTapGesture`) so the double-click recognizer // doesn't claim exclusive priority over the List's selection gesture — the // documented macOS drop-click race (S4 SW2; mirrors PlaylistItemList's rationale). diff --git a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift index 250ae25..f4af19f 100644 --- a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift +++ b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift @@ -164,6 +164,9 @@ struct FacetTrackListView: View { private func row(for track: LibraryTrackDisplay, leadingNumber: Int?, secondary: String) -> some View { TrackRow(track: track, leadingNumber: leadingNumber, secondary: secondary) + // Drag a track onto a sidebar playlist (US-PLIST-03) — reference-add by id, never a file + // move. A List row's drag SOURCE works (only its .dropDestination doesn't). + .draggable(LibraryTrackDragItem(trackID: track.id)) // `.simultaneousGesture` (not `.onTapGesture`) so the double-click recognizer doesn't // claim exclusive priority over the List's selection gesture — the documented macOS // drop-click race (S4 SW2; mirrors PlaylistItemList's rationale). diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar+Rename.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar+Rename.swift new file mode 100644 index 0000000..17e009a --- /dev/null +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar+Rename.swift @@ -0,0 +1,93 @@ +import LibraryStore +import SwiftUI + +// MARK: - LibrarySidebar + inline rename (split out for type-body length) + +/// The playlist inline-rename flow (design §4). A same-type extension — split from `LibrarySidebar` +/// for file/type-body length, like `+Facets`/`+Play`. Reaches the sidebar's `internal` rename state. +/// The transport-Space gate is owned by the field's `.suppressesTransportSpace(while:)` modifier, so +/// `cancelRename` no longer clears it by hand. +extension LibrarySidebar { + /// Create a new untitled playlist, select it, and drop straight into inline-rename (Apple-style). + func createAndBeginRename() async { + guard let id = await playlists.createPlaylist() else { return } + model.selectPlaylist(id) + if let created = playlists.playlists.first(where: { $0.id == id }) { beginRename(created) } + } + + func beginRename(_ playlist: Playlist) { + // Re-entry guard: a rename already in progress for THIS playlist must NOT restart — that would + // reset `editDraft` and wipe the user's in-progress typing (observed: a hijacked Return + // re-invoked this and clobbered the name; see the editing-gated `.onKeyPress` handlers). + guard editingPlaylistID != playlist.id else { return } + editDraft = playlist.name + renameError = nil + editingPlaylistID = playlist.id + // Yield the sidebar's key focus so the rename TextField (focused in its own `.onAppear`) owns + // Return/arrows — otherwise the ScrollView's `.onKeyPress` handlers intercept them. Restored + // on the keyboard close paths (`commitRename`/`onExitCommand`) so ↑/↓ stay alive after. + sidebarFocused = false + } + + /// Begin renaming the selected playlist (keyboard Return). `.ignored` unless a playlist row is + /// the current selection, so the event bubbles for categories / drill-downs. + func renameSelectedPlaylist() -> KeyPress.Result { + guard case let .playlist(id) = model.sidebarSelection, + let playlist = playlists.playlists.first(where: { $0.id == id }) else { return .ignored } + beginRename(playlist) + return .handled + } + + /// Commit the rename from a draft captured synchronously at submit time (`proposed`). Empty or + /// unchanged → cancel (no write). On a duplicate name (D-names: globally unique): when committing + /// via Return (`keepOpenOnConflict`) the field stays open with an inline message; on click-away + /// it reverts silently (don't trap a user who's leaving). Guarded so a stale/torn-down field + /// can't commit. + func commitRename(_ playlist: Playlist, proposed: String, keepOpenOnConflict: Bool) { + guard editingPlaylistID == playlist.id else { return } + let name = proposed.trimmingCharacters(in: .whitespacesAndNewlines) + // `keepOpenOnConflict` distinguishes the KEYBOARD paths (Return) from click-away, so it + // doubles as "restore list focus on close" — a keyboard close keeps ↑/↓/Return alive. + guard !name.isEmpty, name != playlist.name else { + finishRename(restoreListFocus: keepOpenOnConflict) + return + } + Task { + do { + try await playlists.renamePlaylist(id: playlist.id, to: name) + // The user may have begun renaming ANOTHER row during the await — only close if THIS + // playlist is still the one being edited (QA break-it #5). + if editingPlaylistID == playlist.id { finishRename(restoreListFocus: keepOpenOnConflict) } + } catch let conflict as PlaylistNameConflict where keepOpenOnConflict { + showRenameError("“\(conflict.name)” already exists.") + } catch PlaylistMutationError.invalidName where keepOpenOnConflict { + showRenameError("That name can’t be used.") // reserved ("current") — empty is pre-guarded + } catch { + if keepOpenOnConflict { + showRenameError("Couldn’t rename this playlist.") + } else { + cancelRename() // leaving the field: revert rather than trap on the error + } + } + } + } + + /// Close the rename field; on a KEYBOARD close (Return/Escape) restore list focus so ↑/↓ stay + /// alive — NOT on click-away, where the user intentionally moved focus elsewhere (focus-audit). + func finishRename(restoreListFocus: Bool) { + cancelRename() + if restoreListFocus { sidebarFocused = true } + } + + /// Surface an inline rename error + keep the field open/focused for a retry. + func showRenameError(_ message: String) { + renameError = message + renameFieldFocused = true + } + + func cancelRename() { + editingPlaylistID = nil + editDraft = "" + renameError = nil + } +} diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index ea9e053..defd706 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -1,3 +1,4 @@ +import LibraryBrowseKit import LibraryStore import SwiftUI import UniformTypeIdentifiers @@ -13,23 +14,26 @@ import UniformTypeIdentifiers /// `LibraryBrowseModel` (survives the tab-switch teardown); the capsule is `Color.rowSelected`. ↑/↓ /// walk the unified row order via `.onKeyPress` + `@FocusState` (the `List` freebie, re-created). struct LibrarySidebar: View { - @Environment(LibraryBrowseModel.self) private var model - @Environment(PlaylistsModel.self) private var playlists - /// Suppresses the global Space accelerator while the inline-rename field is focused (S4 SW1). - @Environment(KeyboardTransportFocus.self) private var keyboardFocus + // `internal` (not `private`) so the same-type `LibrarySidebar+Rename` extension (split out for + // file/type-body length) can reach them — an extension of this type IS this type. + @Environment(LibraryBrowseModel.self) var model + @Environment(PlaylistsModel.self) var playlists @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var showFolderImporter = false // Inline-rename state (design §4: editing id in parent @State). `editDraft` is the field text; // `renameError` shows an inline conflict message and keeps the field open. - @State private var editingPlaylistID: Int64? - @State private var editDraft = "" - @State private var renameError: String? - @FocusState private var renameFieldFocused: Bool + @State var editingPlaylistID: Int64? + @State var editDraft = "" + @State var renameError: String? + /// The playlist row a library-track drag is hovering over (drop highlight), or nil. + @State private var dropTargetPlaylistID: Int64? + @FocusState var renameFieldFocused: Bool /// Keyboard-command focus for the scroll area (a ScrollView/LazyVStack doesn't own key focus the /// way a `List` does — same `.focusable`/`.focused`/`.defaultFocus` pattern the queue uses). - @FocusState private var sidebarFocused: Bool + /// `internal` for the same-type `LibrarySidebar+Rename` extension (focus yield/restore). + @FocusState var sidebarFocused: Bool /// Music Folders accordion expand/collapse — persisted across launches and view recreation /// (design §8), matching the `.v1`-key `@AppStorage` convention `EQTabView` uses. @@ -158,8 +162,22 @@ struct LibrarySidebar: View { .foregroundStyle(DesignSystem.Color.labelTertiary) } } + .overlay( // drop-target ring while a library track is dragged over this row + RoundedRectangle(cornerRadius: DesignSystem.Radius.control) + .stroke(DesignSystem.Color.accent, + lineWidth: dropTargetPlaylistID == playlist.id ? 1.5 : 0) + ) } .buttonStyle(.plain) + // Drop a dragged library track (US-PLIST-03) → reference-ADD by id (PlaylistDropRouter is + // add-only by construction; no file move/copy). A file-URL/audio drag can't match the + // `LibraryTrackDragItem` type, so it never reaches here. + .dropDestination(for: LibraryTrackDragItem.self) { items, _ in + handleTrackDrop(items, onto: playlist) + } isTargeted: { targeted in + dropTargetPlaylistID = targeted ? playlist.id + : (dropTargetPlaylistID == playlist.id ? nil : dropTargetPlaylistID) + } // Double-click to rename (Finder/Music convention) — the discoverable gesture alongside // the context-menu Rename + the Return key. `.simultaneousGesture` so it coexists with // the Button's single-click select (plain Buttons in a LazyVStack, not a List — no race). @@ -234,34 +252,19 @@ struct LibrarySidebar: View { // MARK: - Actions - /// Create a new untitled playlist, select it, and drop straight into inline-rename (Apple-style). - private func createAndBeginRename() async { - guard let id = await playlists.createPlaylist() else { return } - model.selectPlaylist(id) - if let created = playlists.playlists.first(where: { $0.id == id }) { beginRename(created) } - } - - private func beginRename(_ playlist: Playlist) { - // Re-entry guard: a rename already in progress for THIS playlist must NOT restart — that would - // reset `editDraft` and wipe the user's in-progress typing (observed: a hijacked Return - // re-invoked this and clobbered the name; see the editing-gated `.onKeyPress` handlers). - guard editingPlaylistID != playlist.id else { return } - editDraft = playlist.name - renameError = nil - editingPlaylistID = playlist.id - // Yield the sidebar's key focus so the rename TextField (focused in its own `.onAppear`) owns - // Return/arrows — otherwise the ScrollView's `.onKeyPress` handlers intercept them. Restored - // on the keyboard close paths (`commitRename`/`onExitCommand`) so ↑/↓ stay alive after. - sidebarFocused = false - } - - /// Begin renaming the selected playlist (keyboard Return). `.ignored` unless a playlist row is - /// the current selection, so the event bubbles for categories / drill-downs. - private func renameSelectedPlaylist() -> KeyPress.Result { - guard case let .playlist(id) = model.sidebarSelection, - let playlist = playlists.playlists.first(where: { $0.id == id }) else { return .ignored } - beginRename(playlist) - return .handled + /// Reference-add dropped library tracks to `playlist` (US-PLIST-03/04). Routes through the + /// add-only `PlaylistDropRouter` (no file op is representable), then confirms with a toast. + private func handleTrackDrop(_ items: [LibraryTrackDragItem], onto playlist: Playlist) -> Bool { + dropTargetPlaylistID = nil + guard case let .addTracks(ids) = PlaylistDropRouter.route(droppedTrackIDs: items.map(\.trackID)), + !ids.isEmpty else { return false } + Task { + let added = await playlists.addTracks(ids, toPlaylist: playlist.id) + if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: playlist.name) { + model.showToast(message) + } + } + return true } /// Delete a playlist; if it was the open/selected one, redirect nav back to the current category @@ -275,60 +278,6 @@ struct LibrarySidebar: View { } } - /// Commit the rename from a draft captured synchronously at submit time (`proposed`). Empty or - /// unchanged → cancel (no write). On a duplicate name (D-names: globally unique): when committing - /// via Return (`keepOpenOnConflict`) the field stays open with an inline message; on click-away - /// it reverts silently (don't trap a user who's leaving). Guarded so a stale/torn-down field - /// can't commit. - private func commitRename(_ playlist: Playlist, proposed: String, keepOpenOnConflict: Bool) { - guard editingPlaylistID == playlist.id else { return } - let name = proposed.trimmingCharacters(in: .whitespacesAndNewlines) - // `keepOpenOnConflict` distinguishes the KEYBOARD paths (Return) from click-away, so it - // doubles as "restore list focus on close" — a keyboard close keeps ↑/↓/Return alive. - guard !name.isEmpty, name != playlist.name else { - finishRename(restoreListFocus: keepOpenOnConflict) - return - } - Task { - do { - try await playlists.renamePlaylist(id: playlist.id, to: name) - // The user may have begun renaming ANOTHER row during the await — only close if THIS - // playlist is still the one being edited (QA break-it #5). - if editingPlaylistID == playlist.id { finishRename(restoreListFocus: keepOpenOnConflict) } - } catch let conflict as PlaylistNameConflict where keepOpenOnConflict { - showRenameError("“\(conflict.name)” already exists.") - } catch PlaylistMutationError.invalidName where keepOpenOnConflict { - showRenameError("That name can’t be used.") // reserved ("current") — empty is pre-guarded - } catch { - if keepOpenOnConflict { - showRenameError("Couldn’t rename this playlist.") - } else { - cancelRename() // leaving the field: revert rather than trap on the error - } - } - } - } - - /// Close the rename field; on a KEYBOARD close (Return/Escape) restore list focus so ↑/↓ stay - /// alive — NOT on click-away, where the user intentionally moved focus elsewhere (focus-audit). - private func finishRename(restoreListFocus: Bool) { - cancelRename() - if restoreListFocus { sidebarFocused = true } - } - - /// Surface an inline rename error + keep the field open/focused for a retry. - private func showRenameError(_ message: String) { - renameError = message - renameFieldFocused = true - } - - private func cancelRename() { - editingPlaylistID = nil - editDraft = "" - renameError = nil - keyboardFocus.isTextEntryFocused = false - } - /// Move the unified selection by `delta` rows through `selectables` (keyboard ↑/↓). `.ignored` /// when the move would leave the list, so the event can bubble. Also `.ignored` while a browse /// drill-down (album/artist/genre detail) is showing: `sidebarSelection` collapses that to its diff --git a/Sources/LibraryBrowseKit/PlaylistDropRouter.swift b/Sources/LibraryBrowseKit/PlaylistDropRouter.swift new file mode 100644 index 0000000..35065cd --- /dev/null +++ b/Sources/LibraryBrowseKit/PlaylistDropRouter.swift @@ -0,0 +1,19 @@ +// MARK: - PlaylistDropRouter (S10.3 US-PLIST-03/04 — a drop onto a playlist is ADD-ONLY) + +/// What a drop of library tracks onto a playlist resolves to. There is EXACTLY ONE case: adding +/// track ids by reference. The absence of any `moveFile`/`copyFile` case makes "a drop can never +/// touch the filesystem" a TYPE-LEVEL guarantee (US-PLIST-04), not just a convention — the drop +/// handler can only ever hand these ids to `appendEntries`. +public enum PlaylistDropOutcome: Equatable { + case addTracks([Int64]) +} + +/// Routes a library-track drop onto a playlist. Pure + testable so the add-only contract is provable +/// without SwiftUI or the store. The drop destination is typed to `LibraryTrackDragItem` (a track +/// id), so a file-URL / audio-file drag never matches in the first place; this collapses whatever +/// track ids arrived into the reference-add, de-duplicated in first-seen order. +public enum PlaylistDropRouter { + public static func route(droppedTrackIDs ids: [Int64]) -> PlaylistDropOutcome { + .addTracks(PlaylistAddDecision.trackIDsToAdd(ids)) + } +} diff --git a/Tests/LibraryBrowseKitTests/PlaylistDropRouterTests.swift b/Tests/LibraryBrowseKitTests/PlaylistDropRouterTests.swift new file mode 100644 index 0000000..f842a9b --- /dev/null +++ b/Tests/LibraryBrowseKitTests/PlaylistDropRouterTests.swift @@ -0,0 +1,20 @@ +import LibraryBrowseKit +import Testing + +// MARK: - PlaylistDropRouter (S10.3 — a drop onto a playlist is ADD-ONLY, never a file op) + +@Suite("PlaylistDropRouter — add-only routing") +struct PlaylistDropRouterTests { + /// DROP-1 — a drop of track ids routes to `.addTracks`, de-duplicated in first-seen order. The + /// outcome type has no move/copy case, so "a drop never touches the filesystem" is structural. + @Test("drop routes to add-only, deduped + ordered") + func routesToAddOnly() { + #expect(PlaylistDropRouter.route(droppedTrackIDs: [7, 3, 7, 9, 3]) == .addTracks([7, 3, 9])) + } + + /// DROP-2 — an empty drop is a no-op add (empty ids), never nil / never a file op. + @Test("empty drop → add nothing") + func emptyDrop() { + #expect(PlaylistDropRouter.route(droppedTrackIDs: []) == .addTracks([])) + } +} diff --git a/scripts/strict-gate.sh b/scripts/strict-gate.sh index dab9e85..44ba017 100755 --- a/scripts/strict-gate.sh +++ b/scripts/strict-gate.sh @@ -102,6 +102,25 @@ if ! grep -qE 'eraseDatabaseOnSchemaChange[[:space:]]*=[[:space:]]*false' "$migr fi green "migrator posture ok (eraseDatabaseOnSchemaChange pinned false; never-erase)." +step "Playlist add/drop path must never move or copy files (US-PLIST-04 reference-add)" +# Adding a track to a playlist — via context menu, the picker sheet, or a drag-drop — is a +# REFERENCE-ADD by track id; it must NEVER touch the filesystem. Belt (on top of the type-level +# PlaylistDropRouter add-only outcome + the typed LibraryTrackDragItem drop a file-URL can't match): +# forbid FileManager move/copy anywhere in the playlist UI + model + drop-router + playlist DAO. +playlist_add_paths=( + "Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift" + "Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift" + "Sources/AdaptiveSound/PlaylistsModel.swift" + "Sources/LibraryBrowseKit/PlaylistDropRouter.swift" + "Sources/LibraryStore/LibraryStore+Playlists.swift" +) +if grep -nE '\.(moveItem|copyItem)\(' "${playlist_add_paths[@]}"; then + red "ERROR: a playlist add/drop path references moveItem/copyItem — playlist membership is a" + red " reference-add by track id and must NEVER move or copy a file on disk (US-PLIST-04)." + exit 1 +fi +green "playlist add/drop path ok (no moveItem/copyItem — reference-add only)." + if [[ "$HAVE_CPPCHECK" == "1" ]]; then step "cppcheck (supplementary C++ analysis)" # Start strict-but-useful: warnings/style/perf/portability, not --enable=all (which is From b540423da176277192583d497e901bdcbb386f22 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:56:54 +0530 Subject: [PATCH 15/15] feat(s10.3): Add to Playlist on every browse surface (detail + grid tiles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder by-ear found "Add to Playlist" was missing everywhere except the Songs table (E1 only wired that one) — right-clicking an album/artist showed no such item. Now it sits next to "Add to Queue" on ALL browse surfaces. - AddToPlaylistMenu generalized to an ASYNC id-resolver (`resolveTrackIDs`) so a grid TILE (which holds only an albumID/FacetRef, not loaded tracks) can add on demand, while a detail row just returns its ids. The searchable-picker overflow is optional (`onChooseMore`) — shown on surfaces that host the sheet, hidden on tile menus (which have nowhere to present one: inline playlists + New Playlist). - Wired into: album detail (⋯ = whole album, rows = the track), artist/genre detail (⋯ = whole facet, rows = the track), album grid tiles, artist/genre grid tiles. LibraryBrowseModel.facetTrackIDs added for the facet tiles. - All still reference-add by id (no file move) — covered by the E2 drop-path grep + PlaylistDropRouter's type-level guarantee. strict-gate PASSED. --- .../LibraryBrowseModel+Facets.swift | 6 ++++ .../UI/Library/AlbumDetailView.swift | 12 ++++++++ .../UI/Library/AlbumGridView.swift | 3 ++ .../UI/Library/FacetComponents.swift | 3 ++ .../UI/Library/FacetTrackListView.swift | 12 ++++++++ .../AdaptiveSound/UI/Library/SongsTable.swift | 6 ++-- .../UI/Playlist/AddToPlaylistMenu.swift | 30 +++++++++++-------- 7 files changed, 56 insertions(+), 16 deletions(-) diff --git a/Sources/AdaptiveSound/LibraryBrowseModel+Facets.swift b/Sources/AdaptiveSound/LibraryBrowseModel+Facets.swift index d2ed093..5db7011 100644 --- a/Sources/AdaptiveSound/LibraryBrowseModel+Facets.swift +++ b/Sources/AdaptiveSound/LibraryBrowseModel+Facets.swift @@ -102,6 +102,12 @@ extension LibraryBrowseModel { } } + /// The track ids of a whole facet (artist/genre) — for a tile's reference-add to a playlist + /// (S10.3). Wraps the private `facetTracks` read; empty facet → empty (a no-op add). + func facetTrackIDs(_ ref: FacetRef) async -> [Int64] { + await facetTracks(ref).map(\.id) + } + /// Replace the queue with the whole facet and play (silent, like Play Now everywhere). func playFacet(_ ref: FacetRef) async { let files = await facetTracks(ref).map(AudioFile.init) diff --git a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift index db2bdd6..0a31fec 100644 --- a/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift +++ b/Sources/AdaptiveSound/UI/Library/AlbumDetailView.swift @@ -15,6 +15,8 @@ struct AlbumDetailView: View { @State private var selection = Set() /// The track whose info popover is open (mirrors the Now Playing playlist's Info affordance). @State private var infoTarget: LibraryTrackDisplay? + /// Non-nil while the searchable "Add to Playlist…" picker is open (the host owns the sheet). + @State private var addToPlaylistTarget: AddToPlaylistTarget? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -86,6 +88,7 @@ struct AlbumDetailView: View { Menu { Button("Play Next") { model.playNext(tracks) } Button("Add to Queue") { model.append(tracks) } + addToPlaylistMenu(trackIDs: tracks.map(\.id)) // whole album } label: { Label("More", systemImage: "ellipsis.circle") } @@ -117,6 +120,7 @@ struct AlbumDetailView: View { Button("Play") { model.play(tracks, startAt: index) } Button("Play Next") { model.playNext([track]) } Button("Add to Queue") { model.append([track]) } + addToPlaylistMenu(trackIDs: [track.id]) Divider() Button("Info", systemImage: "info.circle") { infoTarget = track } } @@ -135,6 +139,14 @@ struct AlbumDetailView: View { .scrollContentBackground(.hidden) // Keyboard play (S4 A-M4); `.ignored` on an empty selection so Return bubbles (focus-audit nit). .onKeyPress(.return) { selection.isEmpty ? .ignored : { playSelected(); return .handled }() } + .sheet(item: $addToPlaylistTarget) { PlaylistPickerSheet(trackIDs: $0.trackIDs) } + } + + /// The reference-add "Add to Playlist" submenu (S10.3); overflow opens the searchable picker. + private func addToPlaylistMenu(trackIDs: [Int64]) -> some View { + AddToPlaylistMenu(resolveTrackIDs: { trackIDs }, onChooseMore: { ids in + addToPlaylistTarget = AddToPlaylistTarget(trackIDs: ids) + }) } /// Play the album starting at the selected row — the keyboard Return path (double-click is diff --git a/Sources/AdaptiveSound/UI/Library/AlbumGridView.swift b/Sources/AdaptiveSound/UI/Library/AlbumGridView.swift index b615ac5..a003959 100644 --- a/Sources/AdaptiveSound/UI/Library/AlbumGridView.swift +++ b/Sources/AdaptiveSound/UI/Library/AlbumGridView.swift @@ -152,5 +152,8 @@ struct AlbumQueueActions: View { Button("Play") { Task { await model.playAlbum(albumID) } } Button("Play Next") { Task { await model.playAlbumNext(albumID) } } Button("Add to Queue") { Task { await model.appendAlbum(albumID) } } + // Reference-add the whole album to a playlist; ids resolved on demand (the tile has no loaded + // tracks). No searchable-picker overflow here — a tile menu has no sheet host (S10.3). + AddToPlaylistMenu(resolveTrackIDs: { await model.tracks(inAlbum: albumID).map(\.id) }) } } diff --git a/Sources/AdaptiveSound/UI/Library/FacetComponents.swift b/Sources/AdaptiveSound/UI/Library/FacetComponents.swift index fd5cb10..0c0aa22 100644 --- a/Sources/AdaptiveSound/UI/Library/FacetComponents.swift +++ b/Sources/AdaptiveSound/UI/Library/FacetComponents.swift @@ -14,6 +14,9 @@ struct FacetQueueActions: View { Button("Play") { Task { await model.playFacet(ref) } } Button("Play Next") { Task { await model.playFacetNext(ref) } } Button("Add to Queue") { Task { await model.appendFacet(ref) } } + // Reference-add the whole artist/genre to a playlist; ids resolved on demand. No picker + // overflow — a tile menu has no sheet host (S10.3). + AddToPlaylistMenu(resolveTrackIDs: { await model.facetTrackIDs(ref) }) } } diff --git a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift index f4af19f..4b0382e 100644 --- a/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift +++ b/Sources/AdaptiveSound/UI/Library/FacetTrackListView.swift @@ -24,6 +24,8 @@ struct FacetTrackListView: View { @Environment(LibraryBrowseModel.self) private var model @State private var selection = Set() @State private var infoTarget: LibraryTrackDisplay? + /// Non-nil while the searchable "Add to Playlist…" picker is open (the host owns the sheet). + @State private var addToPlaylistTarget: AddToPlaylistTarget? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -39,6 +41,14 @@ struct FacetTrackListView: View { if groupByAlbum { groupedList } else { flatList } } } + .sheet(item: $addToPlaylistTarget) { PlaylistPickerSheet(trackIDs: $0.trackIDs) } + } + + /// The reference-add "Add to Playlist" submenu (S10.3); overflow opens the searchable picker. + private func addToPlaylistMenu(trackIDs: [Int64]) -> some View { + AddToPlaylistMenu(resolveTrackIDs: { trackIDs }, onChooseMore: { ids in + addToPlaylistTarget = AddToPlaylistTarget(trackIDs: ids) + }) } // MARK: Back bar (⌘[ / in-content — the window toolbar is hidden under the custom chrome) @@ -107,6 +117,7 @@ struct FacetTrackListView: View { Menu { Button("Play Next") { model.playNext(tracks) } Button("Add to Queue") { model.append(tracks) } + addToPlaylistMenu(trackIDs: tracks.map(\.id)) // the whole artist/genre } label: { Label("More", systemImage: "ellipsis.circle") } @@ -181,6 +192,7 @@ struct FacetTrackListView: View { Button("Play") { playFromRow(track) } Button("Play Next") { model.playNext([track]) } Button("Add to Queue") { model.append([track]) } + addToPlaylistMenu(trackIDs: [track.id]) Divider() Button("Info", systemImage: "info.circle") { infoTarget = track } } diff --git a/Sources/AdaptiveSound/UI/Library/SongsTable.swift b/Sources/AdaptiveSound/UI/Library/SongsTable.swift index a2b1381..9036cd1 100644 --- a/Sources/AdaptiveSound/UI/Library/SongsTable.swift +++ b/Sources/AdaptiveSound/UI/Library/SongsTable.swift @@ -196,9 +196,9 @@ struct SongsTable: View { /// The reference-add "Add to Playlist" submenu (S10.3); overflow opens the searchable picker. private func addToPlaylistMenu(trackIDs: [Int64]) -> some View { - AddToPlaylistMenu(trackIDs: trackIDs) { - addToPlaylistTarget = AddToPlaylistTarget(trackIDs: trackIDs) - } + AddToPlaylistMenu(resolveTrackIDs: { trackIDs }, onChooseMore: { ids in + addToPlaylistTarget = AddToPlaylistTarget(trackIDs: ids) + }) } } diff --git a/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift index 58f7211..5e1ca54 100644 --- a/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift +++ b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift @@ -4,14 +4,17 @@ import SwiftUI // MARK: - Add to Playlist (S10.3 US-PLIST-02 — context-menu submenu + searchable picker) -/// A reusable "Add to Playlist" submenu for a library-row context menu. Reference-adds the given -/// `trackIDs` (by id — NEVER a file move) via `PlaylistsModel`, with a confirmation toast. Inline: -/// New Playlist + the first few playlists; overflow routes to the searchable `PlaylistPickerSheet` -/// through `onChooseMore` (a context menu can't own a sheet — the host presents it). +/// A reusable "Add to Playlist" submenu for any library context menu (Songs row, album/artist/genre +/// detail, AND the browse tiles). Reference-adds by track id — NEVER a file move. The ids are +/// resolved LAZILY (`resolveTrackIDs`, async) when an action fires, so a TILE can load its +/// album/facet tracks on demand while a detail row just returns its ids. Inline: New Playlist + the +/// first few playlists; the searchable-picker overflow shows only when `onChooseMore` is provided (a +/// context menu can't own a sheet — the host presents it; tile menus pass nil). struct AddToPlaylistMenu: View { - let trackIDs: [Int64] - /// Called when the user picks "Add to Playlist…" — the host opens `PlaylistPickerSheet`. - let onChooseMore: () -> Void + let resolveTrackIDs: () async -> [Int64] + /// Called with the resolved ids when the user picks "Add to Playlist…"; nil (default, for tile + /// menus with no sheet host) hides that item — inline playlists + New Playlist only. + var onChooseMore: (([Int64]) -> Void)? = nil @Environment(PlaylistsModel.self) private var playlists @Environment(LibraryBrowseModel.self) private var library @@ -26,26 +29,27 @@ struct AddToPlaylistMenu: View { ForEach(playlists.playlists.prefix(inlineLimit)) { playlist in Button(playlist.name) { Task { await add(to: playlist) } } } - if playlists.playlists.count > inlineLimit { + if let onChooseMore, playlists.playlists.count > inlineLimit { Divider() - Button("Add to Playlist…") { onChooseMore() } + Button("Add to Playlist…") { Task { onChooseMore(await resolveTrackIDs()) } } } } } - .disabled(trackIDs.isEmpty) } private func add(to playlist: Playlist) async { - let added = await playlists.addTracks(trackIDs, toPlaylist: playlist.id) + let ids = await resolveTrackIDs() + let added = await playlists.addTracks(ids, toPlaylist: playlist.id) if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: playlist.name) { library.showToast(message) } } private func createAndAdd() async { - guard let id = await playlists.createPlaylist(withTracks: trackIDs) else { return } + let ids = await resolveTrackIDs() + guard let id = await playlists.createPlaylist(withTracks: ids) else { return } let name = playlists.playlists.first { $0.id == id }?.name ?? "New Playlist" - let added = PlaylistAddDecision.trackIDsToAdd(trackIDs).count + let added = PlaylistAddDecision.trackIDsToAdd(ids).count if let message = PlaylistAddDecision.toastMessage(added: added, playlistName: name) { library.showToast(message) }