feat: wire smart playlists into UI — search save, bar, context menus, selection

feat/music-streaming
Laurent 1 month ago
parent e6ae6c5266
commit ac4e421340
  1. 133
      Music/ContentView.swift
  2. 124
      Music/ViewModels/PlaylistViewModel.swift
  3. 44
      Music/Views/PlaylistBarView.swift
  4. 12
      Music/Views/SearchBarView.swift

@ -10,19 +10,28 @@ struct ContentView: View {
var shazam: ShazamService var shazam: ShazamService
@Binding var showNewPlaylistAlert: Bool @Binding var showNewPlaylistAlert: Bool
@State private var showRenameAlert = false @State private var showRenameAlert = false
@State private var showEditQueryAlert = false
@State private var playlistNameInput = "" @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
SearchBarView( SearchBarView(
trackCount: playlist.selectedPlaylist != nil ? playlist.playlistTracks.count : library.trackCount, searchText: $searchText,
trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount,
onSearch: { text in onSearch: { text in
library.search(text) library.search(text)
if playlist.selectedPlaylist != nil { if playlist.selectedPlaylist != nil {
playlist.search(text) playlist.search(text)
} }
}, },
onSaveSearch: { query in
try? playlist.createSmartPlaylist(searchQuery: query)
},
isShazamListening: shazam.isListening, isShazamListening: shazam.isListening,
onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() } onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() }
) )
@ -38,9 +47,12 @@ struct ContentView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
if let selected = playlist.selectedPlaylist { if let selected = playlist.selectedItem {
HStack(spacing: 4) { HStack(spacing: 4) {
Button(action: { playlist.deselectPlaylist() }) { Button(action: {
playlist.deselectPlaylist()
searchText = ""
}) {
HStack(spacing: 2) { HStack(spacing: 2) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 10)) .font(.system(size: 10))
@ -64,15 +76,17 @@ struct ContentView: View {
} }
TrackTableView( TrackTableView(
tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks, tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id, playingTrackId: player.currentTrack?.id,
sortColumn: library.sortColumn,
sortAscending: library.sortAscending,
onSort: { column in onSort: { column in
if playlist.selectedPlaylist == nil { if playlist.selectedItem == nil {
library.sort(by: column) library.sort(by: column)
} }
}, },
onDoubleClick: { track in 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.setQueue(trackList)
player.play(track) player.play(track)
}, },
@ -95,38 +109,44 @@ struct ContentView: View {
if let selected = playlist.selectedPlaylist { if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to) try? playlist.moveTrack(in: selected, from: from, to: to)
} }
} : nil } : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
) )
PlaylistBarView( PlaylistBarView(
playlists: playlist.playlists, playlists: playlist.allPlaylists,
selectedPlaylist: playlist.selectedPlaylist, selectedItem: playlist.selectedItem,
onSelect: { playlist.selectPlaylist($0) }, onSelect: { item in
onDeselect: { playlist.deselectPlaylist() }, playlist.selectItem(item)
onRename: { p in if let smart = item as? SmartPlaylist {
playlistToRename = p searchText = smart.searchQuery
playlistNameInput = p.name library.search(smart.searchQuery)
}
},
onDeselect: {
playlist.deselectPlaylist()
searchText = ""
},
onRename: { item in
itemToRename = item
playlistNameInput = item.name
showRenameAlert = true showRenameAlert = true
}, },
onDelete: { p in onDelete: { item in
try? playlist.deletePlaylist(p) 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( playerControls
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() }
)
} }
.onDrop(of: [.fileURL], isTargeted: nil) { providers in .onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers) handleDrop(providers)
@ -146,16 +166,36 @@ struct ContentView: View {
playlistNameInput = "" playlistNameInput = ""
} }
} }
.alert("Rename Playlist", isPresented: $showRenameAlert) { .alert("Rename", isPresented: $showRenameAlert) {
TextField("Playlist name", text: $playlistNameInput) TextField("Name", text: $playlistNameInput)
Button("Cancel", role: .cancel) { playlistNameInput = "" } Button("Cancel", role: .cancel) { playlistNameInput = "" }
Button("Rename") { Button("Rename") {
let name = playlistNameInput.trimmingCharacters(in: .whitespaces) let name = playlistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let p = playlistToRename { if !name.isEmpty, let item = itemToRename {
try? playlist.renamePlaylist(p, to: name) 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 = "" 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( .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]) { private func handleDrop(_ providers: [NSItemProvider]) {
for provider in providers { for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in

@ -5,9 +5,24 @@ import GRDB
@Observable @Observable
final class PlaylistViewModel { final class PlaylistViewModel {
var playlists: [Playlist] = [] var playlists: [Playlist] = []
var selectedPlaylist: Playlist? var smartPlaylists: [SmartPlaylist] = []
var selectedItem: (any PlaylistRepresentable)?
var playlistTracks: [Track] = [] 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? { var lastUsedPlaylistId: Int64? {
get { UserDefaults.standard.object(forKey: "lastUsedPlaylistId") as? Int64 } get { UserDefaults.standard.object(forKey: "lastUsedPlaylistId") as? Int64 }
set { UserDefaults.standard.set(newValue, forKey: "lastUsedPlaylistId") } set { UserDefaults.standard.set(newValue, forKey: "lastUsedPlaylistId") }
@ -20,15 +35,21 @@ final class PlaylistViewModel {
private let db: DatabaseService private let db: DatabaseService
private var playlistsCancellable: AnyDatabaseCancellable? private var playlistsCancellable: AnyDatabaseCancellable?
private var smartPlaylistsCancellable: AnyDatabaseCancellable?
private var tracksCancellable: AnyDatabaseCancellable? private var tracksCancellable: AnyDatabaseCancellable?
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
private var searchText = "" private var searchText = ""
var sortColumn = "title"
var sortAscending = true
init(db: DatabaseService) { init(db: DatabaseService) {
self.db = db self.db = db
observePlaylists() observePlaylists()
observeSmartPlaylists()
} }
// MARK: - Regular Playlists
func createPlaylist(name: String) throws { func createPlaylist(name: String) throws {
_ = try db.createPlaylist(name: name) _ = try db.createPlaylist(name: name)
} }
@ -71,29 +92,70 @@ final class PlaylistViewModel {
try db.reorderPlaylistTrack(playlistId: playlistId, fromPosition: source, toPosition: destination) try db.reorderPlaylistTrack(playlistId: playlistId, fromPosition: source, toPosition: destination)
} }
func search(_ text: String) { // MARK: - Smart Playlists
searchText = text
searchTask?.cancel() func createSmartPlaylist(searchQuery: String) throws {
searchTask = Task { @MainActor [weak self] in let name = searchQuery
try? await Task.sleep(for: .milliseconds(150)) .split(separator: " ")
guard !Task.isCancelled else { return } .map { $0.prefix(1).uppercased() + $0.dropFirst().lowercased() }
self?.observePlaylistTracks() .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) { // MARK: - Selection
selectedPlaylist = playlist
observePlaylistTracks() 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() { func deselectPlaylist() {
selectedPlaylist = nil selectedItem = nil
tracksCancellable?.cancel() tracksCancellable?.cancel()
tracksCancellable = nil tracksCancellable = nil
playlistTracks = [] playlistTracks = []
searchText = "" 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() { private func observePlaylists() {
let observation = ValueObservation.tracking { [db] dbAccess in let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchPlaylists(db: dbAccess) try db.fetchPlaylists(db: dbAccess)
@ -101,7 +163,28 @@ final class PlaylistViewModel {
playlistsCancellable = observation.start( playlistsCancellable = observation.start(
in: db.dbPool, in: db.dbPool,
onError: { error in print("Playlists observation error: \(error)") }, 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 } 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 }
)
}
} }

@ -1,31 +1,36 @@
import SwiftUI import SwiftUI
struct PlaylistBarView: View { struct PlaylistBarView: View {
var playlists: [Playlist] var playlists: [any PlaylistRepresentable]
var selectedPlaylist: Playlist? var selectedItem: (any PlaylistRepresentable)?
var onSelect: (Playlist) -> Void var onSelect: (any PlaylistRepresentable) -> Void
var onDeselect: () -> Void var onDeselect: () -> Void
var onRename: (Playlist) -> Void var onRename: (any PlaylistRepresentable) -> Void
var onDelete: (Playlist) -> Void var onDelete: (any PlaylistRepresentable) -> Void
var onEditQuery: (SmartPlaylist) -> Void
var body: some View { var body: some View {
if !playlists.isEmpty { if !playlists.isEmpty {
FlowLayout(spacing: 6) { FlowLayout(spacing: 6) {
ForEach(playlists) { playlist in ForEach(playlists, id: \.id) { item in
PlaylistButton( PlaylistButton(
name: playlist.name, name: item.name,
isSelected: selectedPlaylist?.id == playlist.id, isSelected: selectedItem?.id == item.id,
isSmart: item.isSmartPlaylist,
action: { action: {
if selectedPlaylist?.id == playlist.id { if selectedItem?.id == item.id {
onDeselect() onDeselect()
} else { } else {
onSelect(playlist) onSelect(item)
} }
} }
) )
.contextMenu { .contextMenu {
Button("Rename...") { onRename(playlist) } Button("Rename...") { onRename(item) }
Button("Delete") { onDelete(playlist) } 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 { private struct PlaylistButton: View {
let name: String let name: String
let isSelected: Bool let isSelected: Bool
let isSmart: Bool
let action: () -> Void let action: () -> Void
private var tintColor: Color {
isSmart ? .purple : .accentColor
}
private var inactiveColor: Color {
isSmart ? .purple.opacity(0.7) : .secondary
}
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(name) Text(name)
.font(.system(size: 11)) .font(.system(size: 11))
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 5)
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) .background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1))
.foregroundStyle(isSelected ? Color.accentColor : .secondary) .foregroundStyle(isSelected ? tintColor : inactiveColor)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 4) 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) .cornerRadius(4)
} }

@ -1,9 +1,10 @@
import SwiftUI import SwiftUI
struct SearchBarView: View { struct SearchBarView: View {
@State private var searchText = "" @Binding var searchText: String
let trackCount: Int let trackCount: Int
let onSearch: (String) -> Void let onSearch: (String) -> Void
let onSaveSearch: (String) -> Void
let isShazamListening: Bool let isShazamListening: Bool
let onShazam: () -> Void let onShazam: () -> Void
@ -26,6 +27,15 @@ struct SearchBarView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button {
onSaveSearch(searchText)
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Save as smart playlist")
} }
} }
.padding(8) .padding(8)

Loading…
Cancel
Save