From ac4e4213405e0f6ad2967534cc79972dd0edc551 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 26 May 2026 01:34:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20wire=20smart=20playlists=20into=20UI=20?= =?UTF-8?q?=E2=80=94=20search=20save,=20bar,=20context=20menus,=20selectio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Music/ContentView.swift | 133 +++++++++++++++++------ Music/ViewModels/PlaylistViewModel.swift | 124 ++++++++++++++++++--- Music/Views/PlaylistBarView.swift | 44 +++++--- Music/Views/SearchBarView.swift | 12 +- 4 files changed, 248 insertions(+), 65 deletions(-) diff --git a/Music/ContentView.swift b/Music/ContentView.swift index d542219..aa32f61 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -10,19 +10,28 @@ struct ContentView: View { var shazam: ShazamService @Binding var showNewPlaylistAlert: Bool @State private var showRenameAlert = false + @State private var showEditQueryAlert = false @State private var playlistNameInput = "" - @State private var playlistToRename: Playlist? + @State private var editQueryInput = "" + @State private var itemToRename: (any PlaylistRepresentable)? + @State private var smartPlaylistToEdit: SmartPlaylist? + @State private var scrollToPlayingTrigger = UUID() + @State private var searchText = "" var body: some View { VStack(spacing: 0) { SearchBarView( - trackCount: playlist.selectedPlaylist != nil ? playlist.playlistTracks.count : library.trackCount, + searchText: $searchText, + trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount, onSearch: { text in library.search(text) if playlist.selectedPlaylist != nil { playlist.search(text) } }, + onSaveSearch: { query in + try? playlist.createSmartPlaylist(searchQuery: query) + }, isShazamListening: shazam.isListening, onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() } ) @@ -38,9 +47,12 @@ struct ContentView: View { .padding(.vertical, 4) } - if let selected = playlist.selectedPlaylist { + if let selected = playlist.selectedItem { HStack(spacing: 4) { - Button(action: { playlist.deselectPlaylist() }) { + Button(action: { + playlist.deselectPlaylist() + searchText = "" + }) { HStack(spacing: 2) { Image(systemName: "chevron.left") .font(.system(size: 10)) @@ -64,15 +76,17 @@ struct ContentView: View { } TrackTableView( - tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks, + tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks, playingTrackId: player.currentTrack?.id, + sortColumn: library.sortColumn, + sortAscending: library.sortAscending, onSort: { column in - if playlist.selectedPlaylist == nil { + if playlist.selectedItem == nil { library.sort(by: column) } }, onDoubleClick: { track in - let trackList = playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks + let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks player.setQueue(trackList) player.play(track) }, @@ -95,38 +109,44 @@ struct ContentView: View { if let selected = playlist.selectedPlaylist { try? playlist.moveTrack(in: selected, from: from, to: to) } - } : nil + } : nil, + scrollToPlayingTrigger: scrollToPlayingTrigger ) PlaylistBarView( - playlists: playlist.playlists, - selectedPlaylist: playlist.selectedPlaylist, - onSelect: { playlist.selectPlaylist($0) }, - onDeselect: { playlist.deselectPlaylist() }, - onRename: { p in - playlistToRename = p - playlistNameInput = p.name + playlists: playlist.allPlaylists, + selectedItem: playlist.selectedItem, + onSelect: { item in + playlist.selectItem(item) + if let smart = item as? SmartPlaylist { + searchText = smart.searchQuery + library.search(smart.searchQuery) + } + }, + onDeselect: { + playlist.deselectPlaylist() + searchText = "" + }, + onRename: { item in + itemToRename = item + playlistNameInput = item.name showRenameAlert = true }, - onDelete: { p in - try? playlist.deletePlaylist(p) + onDelete: { item in + if let p = item as? Playlist { + try? playlist.deletePlaylist(p) + } else if let sp = item as? SmartPlaylist { + try? playlist.deleteSmartPlaylist(sp) + } + }, + onEditQuery: { smart in + smartPlaylistToEdit = smart + editQueryInput = smart.searchQuery + showEditQueryAlert = true } ) - PlayerControlsView( - currentTrack: player.currentTrack, - isPlaying: audio.isPlaying, - currentTime: audio.currentTime, - duration: audio.duration, - volume: audio.volume, - isShuffled: player.isShuffled, - onPlayPause: { audio.togglePlayPause() }, - onNext: { player.next() }, - onPrevious: { player.previous() }, - onSeek: { audio.seek(to: $0) }, - onVolumeChange: { audio.volume = $0 }, - onShuffleToggle: { player.toggleShuffle() } - ) + playerControls } .onDrop(of: [.fileURL], isTargeted: nil) { providers in handleDrop(providers) @@ -146,16 +166,36 @@ struct ContentView: View { playlistNameInput = "" } } - .alert("Rename Playlist", isPresented: $showRenameAlert) { - TextField("Playlist name", text: $playlistNameInput) + .alert("Rename", isPresented: $showRenameAlert) { + TextField("Name", text: $playlistNameInput) Button("Cancel", role: .cancel) { playlistNameInput = "" } Button("Rename") { let name = playlistNameInput.trimmingCharacters(in: .whitespaces) - if !name.isEmpty, let p = playlistToRename { - try? playlist.renamePlaylist(p, to: name) + if !name.isEmpty, let item = itemToRename { + if let p = item as? Playlist { + try? playlist.renamePlaylist(p, to: name) + } else if let sp = item as? SmartPlaylist { + try? playlist.renameSmartPlaylist(sp, to: name) + } } playlistNameInput = "" - playlistToRename = nil + itemToRename = nil + } + } + .alert("Edit Search Query", isPresented: $showEditQueryAlert) { + TextField("Search query", text: $editQueryInput) + Button("Cancel", role: .cancel) { editQueryInput = "" } + Button("Save") { + let query = editQueryInput.trimmingCharacters(in: .whitespaces) + if !query.isEmpty, let sp = smartPlaylistToEdit { + try? playlist.updateSmartPlaylistQuery(sp, to: query) + if playlist.selectedSmartPlaylist?.id == sp.id { + searchText = query + library.search(query) + } + } + editQueryInput = "" + smartPlaylistToEdit = nil } } .alert("Song Identified", isPresented: Binding( @@ -180,6 +220,27 @@ struct ContentView: View { } } + private var playerControls: some View { + PlayerControlsView( + currentTrack: player.currentTrack, + isPlaying: audio.isPlaying, + currentTime: audio.currentTime, + duration: audio.duration, + volume: audio.volume, + isShuffled: player.isShuffled, + onPlayPause: { audio.togglePlayPause() }, + onNext: { player.next() }, + onPrevious: { player.previous() }, + onSeek: { audio.seek(to: $0) }, + onScrubStart: { audio.beginScrubbing() }, + onScrub: { audio.scrub(to: $0) }, + onScrubEnd: { audio.endScrubbing(at: $0) }, + onVolumeChange: { audio.volume = $0 }, + onShuffleToggle: { player.toggleShuffle() }, + onNowPlayingTap: { scrollToPlayingTrigger = UUID() } + ) + } + private func handleDrop(_ providers: [NSItemProvider]) { for provider in providers { provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in diff --git a/Music/ViewModels/PlaylistViewModel.swift b/Music/ViewModels/PlaylistViewModel.swift index e58541a..5a7f9ac 100644 --- a/Music/ViewModels/PlaylistViewModel.swift +++ b/Music/ViewModels/PlaylistViewModel.swift @@ -5,9 +5,24 @@ import GRDB @Observable final class PlaylistViewModel { var playlists: [Playlist] = [] - var selectedPlaylist: Playlist? + var smartPlaylists: [SmartPlaylist] = [] + var selectedItem: (any PlaylistRepresentable)? var playlistTracks: [Track] = [] + var allPlaylists: [any PlaylistRepresentable] { + let regular: [any PlaylistRepresentable] = playlists + let smart: [any PlaylistRepresentable] = smartPlaylists + return (regular + smart).sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + var selectedPlaylist: Playlist? { + selectedItem as? Playlist + } + + var selectedSmartPlaylist: SmartPlaylist? { + selectedItem as? SmartPlaylist + } + var lastUsedPlaylistId: Int64? { get { UserDefaults.standard.object(forKey: "lastUsedPlaylistId") as? Int64 } set { UserDefaults.standard.set(newValue, forKey: "lastUsedPlaylistId") } @@ -20,15 +35,21 @@ final class PlaylistViewModel { private let db: DatabaseService private var playlistsCancellable: AnyDatabaseCancellable? + private var smartPlaylistsCancellable: AnyDatabaseCancellable? private var tracksCancellable: AnyDatabaseCancellable? private var searchTask: Task? private var searchText = "" + var sortColumn = "title" + var sortAscending = true init(db: DatabaseService) { self.db = db observePlaylists() + observeSmartPlaylists() } + // MARK: - Regular Playlists + func createPlaylist(name: String) throws { _ = try db.createPlaylist(name: name) } @@ -71,29 +92,70 @@ final class PlaylistViewModel { try db.reorderPlaylistTrack(playlistId: playlistId, fromPosition: source, toPosition: destination) } - func search(_ text: String) { - searchText = text - searchTask?.cancel() - searchTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(150)) - guard !Task.isCancelled else { return } - self?.observePlaylistTracks() + // MARK: - Smart Playlists + + func createSmartPlaylist(searchQuery: String) throws { + let name = searchQuery + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst().lowercased() } + .joined(separator: " ") + _ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery) + } + + func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws { + guard let id = smartPlaylist.id else { return } + try db.renameSmartPlaylist(id: id, name: name) + } + + func updateSmartPlaylistQuery(_ smartPlaylist: SmartPlaylist, to query: String) throws { + guard let id = smartPlaylist.id else { return } + try db.updateSmartPlaylistQuery(id: id, searchQuery: query) + if selectedSmartPlaylist?.id == id { + observeSmartPlaylistTracks(searchQuery: query) + } + } + + func deleteSmartPlaylist(_ smartPlaylist: SmartPlaylist) throws { + guard let id = smartPlaylist.id else { return } + if selectedSmartPlaylist?.id == id { + deselectPlaylist() } + try db.deleteSmartPlaylist(id: id) } - func selectPlaylist(_ playlist: Playlist) { - selectedPlaylist = playlist - observePlaylistTracks() + // MARK: - Selection + + func selectItem(_ item: any PlaylistRepresentable) { + selectedItem = item + if item is Playlist { + observePlaylistTracks() + } else if let smart = item as? SmartPlaylist { + observeSmartPlaylistTracks(searchQuery: smart.searchQuery) + } } func deselectPlaylist() { - selectedPlaylist = nil + selectedItem = nil tracksCancellable?.cancel() tracksCancellable = nil playlistTracks = [] searchText = "" } + func search(_ text: String) { + searchText = text + searchTask?.cancel() + searchTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + if self?.selectedPlaylist != nil { + self?.observePlaylistTracks() + } + } + } + + // MARK: - Observation + private func observePlaylists() { let observation = ValueObservation.tracking { [db] dbAccess in try db.fetchPlaylists(db: dbAccess) @@ -101,7 +163,28 @@ final class PlaylistViewModel { playlistsCancellable = observation.start( in: db.dbPool, onError: { error in print("Playlists observation error: \(error)") }, - onChange: { [weak self] playlists in self?.playlists = playlists } + onChange: { [weak self] playlists in + self?.playlists = playlists + if let selectedId = self?.selectedPlaylist?.id { + self?.selectedItem = playlists.first(where: { $0.id == selectedId }) + } + } + ) + } + + private func observeSmartPlaylists() { + let observation = ValueObservation.tracking { [db] dbAccess in + try db.fetchSmartPlaylists(db: dbAccess) + } + smartPlaylistsCancellable = observation.start( + in: db.dbPool, + onError: { error in print("Smart playlists observation error: \(error)") }, + onChange: { [weak self] smartPlaylists in + self?.smartPlaylists = smartPlaylists + if let selectedId = self?.selectedSmartPlaylist?.id { + self?.selectedItem = smartPlaylists.first(where: { $0.id == selectedId }) + } + } ) } @@ -119,4 +202,19 @@ final class PlaylistViewModel { onChange: { [weak self] tracks in self?.playlistTracks = tracks } ) } + + private func observeSmartPlaylistTracks(searchQuery: String) { + tracksCancellable?.cancel() + let col = sortColumn + let asc = sortAscending + + let observation = ValueObservation.tracking { [db] dbAccess in + try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc) + } + tracksCancellable = observation.start( + in: db.dbPool, + onError: { error in print("Smart playlist tracks observation error: \(error)") }, + onChange: { [weak self] tracks in self?.playlistTracks = tracks } + ) + } } diff --git a/Music/Views/PlaylistBarView.swift b/Music/Views/PlaylistBarView.swift index f6fb55e..f74ddcd 100644 --- a/Music/Views/PlaylistBarView.swift +++ b/Music/Views/PlaylistBarView.swift @@ -1,31 +1,36 @@ import SwiftUI struct PlaylistBarView: View { - var playlists: [Playlist] - var selectedPlaylist: Playlist? - var onSelect: (Playlist) -> Void + var playlists: [any PlaylistRepresentable] + var selectedItem: (any PlaylistRepresentable)? + var onSelect: (any PlaylistRepresentable) -> Void var onDeselect: () -> Void - var onRename: (Playlist) -> Void - var onDelete: (Playlist) -> Void + var onRename: (any PlaylistRepresentable) -> Void + var onDelete: (any PlaylistRepresentable) -> Void + var onEditQuery: (SmartPlaylist) -> Void var body: some View { if !playlists.isEmpty { FlowLayout(spacing: 6) { - ForEach(playlists) { playlist in + ForEach(playlists, id: \.id) { item in PlaylistButton( - name: playlist.name, - isSelected: selectedPlaylist?.id == playlist.id, + name: item.name, + isSelected: selectedItem?.id == item.id, + isSmart: item.isSmartPlaylist, action: { - if selectedPlaylist?.id == playlist.id { + if selectedItem?.id == item.id { onDeselect() } else { - onSelect(playlist) + onSelect(item) } } ) .contextMenu { - Button("Rename...") { onRename(playlist) } - Button("Delete") { onDelete(playlist) } + Button("Rename...") { onRename(item) } + if let smart = item as? SmartPlaylist { + Button("Edit Search Query...") { onEditQuery(smart) } + } + Button("Delete") { onDelete(item) } } } } @@ -38,19 +43,28 @@ struct PlaylistBarView: View { private struct PlaylistButton: View { let name: String let isSelected: Bool + let isSmart: Bool let action: () -> Void + private var tintColor: Color { + isSmart ? .purple : .accentColor + } + + private var inactiveColor: Color { + isSmart ? .purple.opacity(0.7) : .secondary + } + var body: some View { Button(action: action) { Text(name) .font(.system(size: 11)) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) - .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundStyle(isSelected ? tintColor : inactiveColor) .overlay( RoundedRectangle(cornerRadius: 4) - .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + .stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1) ) .cornerRadius(4) } diff --git a/Music/Views/SearchBarView.swift b/Music/Views/SearchBarView.swift index 082ff41..550091f 100644 --- a/Music/Views/SearchBarView.swift +++ b/Music/Views/SearchBarView.swift @@ -1,9 +1,10 @@ import SwiftUI struct SearchBarView: View { - @State private var searchText = "" + @Binding var searchText: String let trackCount: Int let onSearch: (String) -> Void + let onSaveSearch: (String) -> Void let isShazamListening: Bool let onShazam: () -> Void @@ -26,6 +27,15 @@ struct SearchBarView: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) + + Button { + onSaveSearch(searchText) + } label: { + Image(systemName: "plus.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Save as smart playlist") } } .padding(8)