diff --git a/Music/ViewModels/PlaylistViewModel.swift b/Music/ViewModels/PlaylistViewModel.swift index 36186dd..99dfd2d 100644 --- a/Music/ViewModels/PlaylistViewModel.swift +++ b/Music/ViewModels/PlaylistViewModel.swift @@ -102,6 +102,10 @@ final class PlaylistViewModel { _ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery) } + func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws { + _ = try db.createSmartPlaylist(name: name, conditions: conditions) + } + func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws { guard let id = smartPlaylist.id else { return } try db.renameSmartPlaylist(id: id, name: name) @@ -111,7 +115,20 @@ final class PlaylistViewModel { guard let id = smartPlaylist.id else { return } try db.updateSmartPlaylistQuery(id: id, searchQuery: query) if selectedSmartPlaylist?.id == id { - observeSmartPlaylistTracks(searchQuery: query) + var updated = smartPlaylist + updated.searchQuery = query + updated.conditions = nil + observeSmartPlaylistTracks(for: updated) + } + } + + func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws { + guard let id = smartPlaylist.id else { return } + try db.updateSmartPlaylistConditions(id: id, conditions: conditions) + if selectedSmartPlaylist?.id == id { + var updated = smartPlaylist + updated.conditions = conditions + observeSmartPlaylistTracks(for: updated) } } @@ -130,7 +147,7 @@ final class PlaylistViewModel { if item is Playlist { observePlaylistTracks() } else if let smart = item as? SmartPlaylist { - observeSmartPlaylistTracks(searchQuery: smart.searchQuery) + observeSmartPlaylistTracks(for: smart) } } @@ -150,7 +167,7 @@ final class PlaylistViewModel { sortAscending = true } if let smart = selectedSmartPlaylist { - observeSmartPlaylistTracks(searchQuery: smart.searchQuery) + observeSmartPlaylistTracks(for: smart) } } @@ -215,13 +232,21 @@ final class PlaylistViewModel { ) } - private func observeSmartPlaylistTracks(searchQuery: String) { + private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) { 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) + let observation: ValueObservation> + if let conditions = smartPlaylist.conditions { + observation = ValueObservation.tracking { [db] dbAccess in + try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc) + } + } else { + let searchQuery = smartPlaylist.searchQuery + observation = ValueObservation.tracking { [db] dbAccess in + try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc) + } } tracksCancellable = observation.start( in: db.dbPool, diff --git a/Music/Views/SmartPlaylistBuilderSheet.swift b/Music/Views/SmartPlaylistBuilderSheet.swift new file mode 100644 index 0000000..05c9925 --- /dev/null +++ b/Music/Views/SmartPlaylistBuilderSheet.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct SmartPlaylistBuilderSheet: View { + var editingPlaylist: SmartPlaylist? + var onSave: (String, [SmartPlaylistCondition]) -> Void + var onCancel: () -> Void + + @State private var name: String + @State private var conditions: [SmartPlaylistCondition] + + init( + editingPlaylist: SmartPlaylist? = nil, + onSave: @escaping (String, [SmartPlaylistCondition]) -> Void, + onCancel: @escaping () -> Void + ) { + self.editingPlaylist = editingPlaylist + self.onSave = onSave + self.onCancel = onCancel + let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")) + _name = State(initialValue: editingPlaylist?.name ?? "") + _conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition]) + } + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + conditions.allSatisfy { !$0.isEmpty } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist") + .font(.headline) + + VStack(alignment: .leading, spacing: 4) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Playlist name", text: $name) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Conditions (all must match)") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(conditions.indices, id: \.self) { index in + ConditionRowView( + condition: $conditions[index], + canRemove: conditions.count > 1, + onRemove: { conditions.remove(at: index) } + ) + } + + Button("+ Add Condition") { + conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .font(.system(size: 12)) + } + + Divider() + + HStack { + Spacer() + Button("Cancel", action: onCancel) + Button("Save") { + onSave(name.trimmingCharacters(in: .whitespaces), conditions) + } + .disabled(!canSave) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 540) + } +} + +private struct ConditionRowView: View { + @Binding var condition: SmartPlaylistCondition + var canRemove: Bool + var onRemove: () -> Void + + var body: some View { + HStack(spacing: 8) { + Picker("", selection: $condition.field) { + ForEach(TrackField.allCases) { field in + Text(field.displayName).tag(field) + } + } + .labelsHidden() + .frame(maxWidth: 130) + .onChange(of: condition.field) { _, newField in + condition.op = newField.validOperators[0] + condition.value = newField.defaultValue + } + + Picker("", selection: $condition.op) { + ForEach(condition.field.validOperators) { op in + Text(op.displayName).tag(op) + } + } + .labelsHidden() + .frame(maxWidth: 130) + + valueField + + Button(action: onRemove) { + Image(systemName: "minus.circle.fill") + .foregroundStyle(Color.secondary.opacity(canRemove ? 1.0 : 0.3)) + } + .buttonStyle(.plain) + .disabled(!canRemove) + } + } + + @ViewBuilder + private var valueField: some View { + switch condition.field.fieldType { + case .string: + TextField("Value", text: Binding( + get: { if case .string(let s) = condition.value { return s } else { return "" } }, + set: { condition.value = .string($0) } + )) + .textFieldStyle(.roundedBorder) + case .int: + TextField("Value", text: Binding( + get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } }, + set: { condition.value = .int(Int($0) ?? 0) } + )) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 100) + case .double: + TextField("Value", text: Binding( + get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } }, + set: { condition.value = .double(Double($0) ?? 0) } + )) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 100) + case .date: + DatePicker("", selection: Binding( + get: { if case .date(let d) = condition.value { return d } else { return Date() } }, + set: { condition.value = .date($0) } + ), displayedComponents: .date) + .labelsHidden() + } + } +}