diff --git a/Sources/AdaptiveSound/AdaptiveSound.swift b/Sources/AdaptiveSound/AdaptiveSound.swift index 4ea7076..42321c9 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) and plays through + // the shared `audio` VM (Play/Next/Queue + restore-queue undo — Chunk C). + _playlistsModel = State(initialValue: PlaylistsModel(library: lib, audio: audio)) // 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/AudioViewModel+Queue.swift b/Sources/AdaptiveSound/AudioViewModel+Queue.swift index 3c61c71..6684c5d 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Queue.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Queue.swift @@ -18,8 +18,50 @@ import PlaybackQueueKit // track may appear more than once — the S9 dedupe-on-add contract is retired. Reorder identity // is `QueueItem.id` (see `movePlaylistItems`). +/// A one-level restore point for the "Play (replace queue)" undo (S10.3): the queue slots + the +/// selected index, snapshotted before a destructive replace. +struct QueueRestorePoint { + let items: [QueueItem] + let index: Int? +} + @MainActor extension AudioViewModel { + /// Replace the queue with `tracks` and start playing from `startAt`, first SNAPSHOTTING the + /// current queue so the caller can offer a one-level "Restore previous queue" undo (S10.3 + /// D-play). Use this for the playlist "Play" verb; plain `playNow` stays the un-undoable path. + func playNowWithUndo(_ tracks: [AudioFile], startAt index: Int = 0) { + guard !tracks.isEmpty else { return } + queueRestorePoint = QueueRestorePoint(items: queue, index: selectedTrackIndex) + playNow(tracks, startAt: index) + } + + /// Whether a "Restore previous queue" undo is available. + var canRestorePreviousQueue: Bool { + queueRestorePoint != nil + } + + /// Restore the queue captured by the last `playNowWithUndo` (one-level). An empty previous queue + /// clears; otherwise the slots are restored and playback resumes at the previously selected track + /// (a pragmatic one-level undo — position within the track is not preserved). A previous queue + /// with NO selection is restored with the engine stopped (never left playing a gone track). + func restorePreviousQueue() { + guard let point = queueRestorePoint else { return } + queueRestorePoint = nil + guard !point.items.isEmpty else { + clearPlaylist() + return + } + queue = point.items + scheduleQueueMirror() + if let index = point.index, index < queue.count { + playTrack(at: index) // resume at the previously-selected track + } else { + selectedTrackIndex = nil + stopPlayback() // no prior selection — restore the slots, stop the engine (no gone-track playback) + } + } + /// Replace the queue with `tracks` and start playing from `startAt` (clamped). /// The destructive "play this album/these results now" action. `startPlayback` /// (via `playTrack`) re-primes the gapless on-deck. diff --git a/Sources/AdaptiveSound/AudioViewModel.swift b/Sources/AdaptiveSound/AudioViewModel.swift index bff08cb..f201ca0 100644 --- a/Sources/AdaptiveSound/AudioViewModel.swift +++ b/Sources/AdaptiveSound/AudioViewModel.swift @@ -202,6 +202,11 @@ final class AudioViewModel { queue.map(\.file) } + /// One-level snapshot of the queue captured just before a destructive "Play (replace queue)" + /// (S10.3 D-play), so the UI can offer a transient "Restore previous queue" undo. Cleared once + /// restored or superseded by the next snapshotting replace. Not UI-bound beyond the undo affordance. + var queueRestorePoint: QueueRestorePoint? + /// Debounce handle for the queue→"current"-playlist snapshot mirror (S10.2, `+QueueMirror`). /// A queue edit schedules a mirror; a newer edit cancels the pending one so only the settled /// queue is written. Advance does NOT mirror (rows don't change). Not UI-bound. 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/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+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..0372991 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) @@ -389,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/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/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/Models/PlaylistEntryDragItem.swift b/Sources/AdaptiveSound/Models/PlaylistEntryDragItem.swift new file mode 100644 index 0000000..35d1055 --- /dev/null +++ b/Sources/AdaptiveSound/Models/PlaylistEntryDragItem.swift @@ -0,0 +1,17 @@ +import CoreTransferable + +/// Transferable payload for dragging a playlist-entry row to reorder it within its playlist (S10.3). +/// Carries the entry's stable `PlaylistEntry.id` (not its index), so the drop resolves the CURRENT +/// position even if the list shifted mid-drag. Same rationale as `QueueDragItem`: a plain-text +/// `ProxyRepresentation` (an undeclared custom UTType silently fails drop-type matching); a foreign +/// text drop imports to an id that matches no entry, so the reorder safely no-ops. +struct PlaylistEntryDragItem: Transferable { + let entryID: Int64 + + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation( + exporting: { (item: PlaylistEntryDragItem) in String(item.entryID) }, + importing: { (string: String) in PlaylistEntryDragItem(entryID: Int64(string) ?? -1) } + ) + } +} diff --git a/Sources/AdaptiveSound/PlaylistsModel.swift b/Sources/AdaptiveSound/PlaylistsModel.swift new file mode 100644 index 0000000..4febc6c --- /dev/null +++ b/Sources/AdaptiveSound/PlaylistsModel.swift @@ -0,0 +1,340 @@ +import Foundation +import LibraryBrowseKit +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 + + /// The playback VM the play verbs route through (Play/Next/Queue + restore-queue undo). Added in + /// Chunk C alongside its UI consumers so it isn't a dead dependency. + let audio: AudioViewModel + + // 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, audio: AudioViewModel) { + self.library = library + self.audio = audio + } + + // 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 (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 + } 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). 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 + } + } + + // 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 + /// 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). 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 + } + + /// 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: - Play verbs (delegate to AudioViewModel; C) + + /// Resolved (playable) entries of the open playlist, in order. Unresolved (missing) entries are + /// dropped — that IS skip-on-play for C; Chunk F adds the unavailable badge + Locate. + private var resolvedEntries: [PlaylistDetailEntry] { + detail.filter { $0.display != nil } + } + + private func playableFiles() -> [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 + +/// 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/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..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") } @@ -99,6 +102,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). @@ -114,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 } } @@ -130,7 +137,16 @@ 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 }() } + .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 50dd819..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") } @@ -126,7 +137,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 +154,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 { @@ -164,6 +175,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). @@ -178,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/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/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/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+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 f822949..defd706 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -1,42 +1,312 @@ +import LibraryBrowseKit +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 + // `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 - /// 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 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). + /// `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. @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() + // ↑/↓/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) { editingPlaylistID == nil ? renameSelectedPlaylist() : .ignored } } - // 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) } - private var footer: some View { + // 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) + } + } + .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). + .simultaneousGesture(TapGesture(count: 2).onEnded { beginRename(playlist) }) + .contextMenu { + Button("Rename") { beginRename(playlist) } + Button("Delete", role: .destructive) { deletePlaylist(playlist) } + } + } + } + + private func renameField(_ playlist: Playlist) -> some View { + VStack(alignment: .leading, spacing: 2) { + TextField("Playlist name", text: $editDraft) + .textFieldStyle(.plain) + .font(DesignSystem.Font.body) + // 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 + if wasFocused, !isFocused, editingPlaylistID == playlist.id { + commitRename(playlist, proposed: editDraft, keepOpenOnConflict: 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) } + .onExitCommand { + cancelRename() + sidebarFocused = true // keyboard close → keep ↑/↓/Return alive (focus-audit MAJOR) + } + .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 + + /// 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 + /// 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 { + let deleted = await playlists.deletePlaylist(id: playlist.id) + if deleted, wasSelected { model.selectCategory(model.selectedCategory ?? .songs) } + } + } + + /// 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 +329,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 +361,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/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/Library/SongsTable.swift b/Sources/AdaptiveSound/UI/Library/SongsTable.swift index ded469e..9036cd1 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(resolveTrackIDs: { trackIDs }, onChooseMore: { ids in + addToPlaylistTarget = AddToPlaylistTarget(trackIDs: ids) + }) + } } // 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..5e1ca54 --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/AddToPlaylistMenu.swift @@ -0,0 +1,141 @@ +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 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 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 + + /// 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 let onChooseMore, playlists.playlists.count > inlineLimit { + Divider() + Button("Add to Playlist…") { Task { onChooseMore(await resolveTrackIDs()) } } + } + } + } + } + + private func add(to playlist: Playlist) async { + 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 { + 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(ids).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", focusesOnAppear: true) + .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/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift new file mode 100644 index 0000000..3d7e611 --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift @@ -0,0 +1,288 @@ +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 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) + 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) + .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) } + } + + // MARK: Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: DesignSystem.Spacing.medium) { + 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() + playVerbs + Text(countLine) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + } + .padding(.horizontal, DesignSystem.LayoutMetrics.screenInsetH) + .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")" + } + + // 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) + } + } + } + .focusable() + .focused($listFocused) + .defaultFocus($listFocused, true) + .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 { + PlaylistItemRow( + file: AudioFile(display), + index: index, + 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 { + 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) + } + + // 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. 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) + } + 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 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/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/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/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/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift new file mode 100644 index 0000000..8bd20a1 --- /dev/null +++ b/Sources/LibraryStore/LibraryStore+PlaylistFolders.swift @@ -0,0 +1,266 @@ +// 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 = ?;" + /// 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`) + /// 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) + } + } + // 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]) + } + } + + // 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..0eb3c2f 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 @@ -118,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 028b86f..284b772 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 @@ -34,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 @@ -82,16 +91,19 @@ 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). /// - 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/ @@ -280,18 +292,27 @@ 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). + /// 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) @@ -319,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) } } @@ -352,12 +375,26 @@ 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 + /// 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 { var migrator = DatabaseMigrator() - migrator.eraseDatabaseOnSchemaChange = true + migrator.eraseDatabaseOnSchemaChange = false migrator.registerMigration(Schema.MigrationID.v1) { db in try Schema.migrateV0toV1(db, appBuild: appBuild, timestamp: nowSeconds()) } @@ -370,6 +407,12 @@ 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()) + } + 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 f175696..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 = 4 +public let currentSchemaVersion = 6 /// The reserved "unknown artist" sentinel rowid seeded at v1 for the M1 album key. public let unknownArtistID: Int64 = 0 @@ -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 @@ -60,6 +60,17 @@ 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" + /// 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 @@ -197,6 +208,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 +427,57 @@ 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) + } + + /// 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 d1a2e69..eb8c612 100644 --- a/Sources/VerifyLibraryStore/Checks.swift +++ b/Sources/VerifyLibraryStore/Checks.swift @@ -65,6 +65,12 @@ 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) + } + migrator.registerMigration(Schema.MigrationID.v6) { db in + try Schema.migrateV5toV6(db, appBuild: "verify", timestamp: testTimestamp) + } return migrator } 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/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 new file mode 100644 index 0000000..e3cbb05 --- /dev/null +++ b/Sources/VerifyLibraryStore/ChecksPlaylistFolders.swift @@ -0,0 +1,238 @@ +// 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), + 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), + ] +} + +// 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 } +} + +/// 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 717faba..260ad06 100644 --- a/Sources/VerifyLibraryStore/main.swift +++ b/Sources/VerifyLibraryStore/main.swift @@ -179,15 +179,16 @@ func allCheckCases() -> [CheckCase] { ] + moveMatchCheckCases() + facetSweepCheckCases() + folderWatchCheckCases() + reachabilityCheckCases() + browseReadsCheckCases() + searchCheckCases() + songsSortCheckCases() + decodeCoverageCheckCases() + hardeningCheckCases() - + playlistSpineCheckCases() + + playlistSpineCheckCases() + playlistFolderCheckCases() + + migrationConvergenceCheckCases() } /// 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/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) + } +} 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/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/docs/sprints/s10-3-playlists-ux-design.md b/docs/sprints/s10-3-playlists-ux-design.md new file mode 100644 index 0000000..d32ea9c --- /dev/null +++ b/docs/sprints/s10-3-playlists-ux-design.md @@ -0,0 +1,91 @@ +# 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 + 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 (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. 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. +- **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). + +## 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. + +## 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. + +## 4. UI (reuse map — corrected) +| Piece | Reuse / build | +|---|---| +| 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. 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`. +- **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** — ✅ 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** — ✅ 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**. +- **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. 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 (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. +- **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). + +--- + +## Deferred to post-R1 — Smart / auto playlists (design preserved) +Vetted but decoupled (D-smart). A later additive migration (v6+) adds `playlists.is_smart` + a normalized **`playlist_rules`** table (in `library.sqlite3`, same store as `tracks`); membership is **derived** (a pure `SmartRuleSQLBuilder` → `WHERE` over `tracks`, run when the smart list is opened — a plain same-store query, no cross-store concern). 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/>/