Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/AdaptiveSound/AdaptiveSound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions Sources/AdaptiveSound/AudioViewModel+Queue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Sources/AdaptiveSound/AudioViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions Sources/AdaptiveSound/LibraryBrowseModel+Facets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions Sources/AdaptiveSound/LibraryBrowseModel+Play.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
33 changes: 33 additions & 0 deletions Sources/AdaptiveSound/LibraryBrowseModel+Sidebar.swift
Original file line number Diff line number Diff line change
@@ -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)]
}
}
70 changes: 20 additions & 50 deletions Sources/AdaptiveSound/LibraryBrowseModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions Sources/AdaptiveSound/LibraryModel+Scan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions Sources/AdaptiveSound/Models/LibraryTrackDragItem.swift
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
17 changes: 17 additions & 0 deletions Sources/AdaptiveSound/Models/PlaylistEntryDragItem.swift
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
Loading
Loading