diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index af8d573..96e0b9c 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -447,7 +447,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; @@ -494,7 +494,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; diff --git a/Music/ContentView.swift b/Music/ContentView.swift index 2dfe29e..5e78a3f 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct TrackInfoRequest: Identifiable { let id = UUID() let tracks: [Track] + let allTracks: [Track] } struct ContentView: View { @@ -18,6 +19,10 @@ struct ContentView: View { @Binding var showNewPlaylistAlert: Bool @Binding var showSmartPlaylistBuilder: Bool var networkStatus: NetworkStatus? + var isHosting: Bool + var onToggleHost: () -> Void + var isStreaming: Bool + var onToggleStream: () -> Void @State private var infoRequest: TrackInfoRequest? @State private var showRenameAlert = false @State private var showEditQueryAlert = false @@ -28,6 +33,8 @@ struct ContentView: View { @State private var editQueryInput = "" @State private var itemToRename: (any PlaylistRepresentable)? @State private var smartPlaylistToEdit: SmartPlaylist? + @State private var tracksToDelete: [Track] = [] + @State private var showDeleteConfirmation = false @State private var scrollToPlayingTrigger = UUID() @State private var searchText = "" @State private var keyMonitor: Any? @@ -112,7 +119,11 @@ struct ContentView: View { try? playlist.createSmartPlaylist(searchQuery: query) }, isShazamListening: shazam.isListening, - onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() } + onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() }, + isHosting: isHosting, + onToggleHost: onToggleHost, + isStreaming: isStreaming, + onToggleStream: onToggleStream ) if scanner.isScanning { @@ -196,6 +207,13 @@ struct ContentView: View { try? playlist.moveTrack(in: selected, from: from, to: to) } } : nil, + onRatingChange: { track, newRating in + var fields = EditableTrackFields(from: track) + fields.rating = newRating + Task { + _ = await library.applyTrackEdits(fields, editing: [.rating], to: [track]) + } + }, scrollToPlayingTrigger: scrollToPlayingTrigger ) } @@ -367,6 +385,23 @@ struct ContentView: View { Text(error) } } + .confirmationDialog( + deleteDialogTitle, + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Remove from Database", role: .destructive) { + try? library.deleteTracks(tracksToDelete, moveToTrash: false) + tracksToDelete = [] + } + Button("Remove and Delete from Disk", role: .destructive) { + try? library.deleteTracks(tracksToDelete, moveToTrash: true) + tracksToDelete = [] + } + Button("Cancel", role: .cancel) { + tracksToDelete = [] + } + } .sheet(isPresented: $showSmartPlaylistBuilder) { SmartPlaylistBuilderSheet( editingPlaylist: nil, @@ -396,14 +431,13 @@ struct ContentView: View { .sheet(item: $infoRequest) { req in TrackInfoSheet( tracks: req.tracks, - onSave: { values, edited in - let targets = req.tracks - infoRequest = nil + allTracks: req.allTracks, + onSave: { values, edited, targets in Task { _ = await library.applyTrackEdits(values, editing: edited, to: targets) } }, - onCancel: { infoRequest = nil } + onDismiss: { infoRequest = nil } ) } } @@ -416,6 +450,10 @@ struct ContentView: View { return false } + private var deleteDialogTitle: String { + tracksToDelete.count == 1 ? "Delete Track" : "Delete \(tracksToDelete.count) Tracks" + } + private var trackContextMenuConfig: TrackContextMenuConfig { // Queue actions are local-only for v1: hidden when driving a remote device. let queueEnabled = !isDrivingRemoteDevice @@ -440,7 +478,14 @@ struct ContentView: View { onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil, onAddToNewPlaylist: { track in newPlaylistTrack = track }, onGetInfo: { tracks in - if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) } + let allTracks = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks + if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks, allTracks: allTracks) } + }, + onDelete: { tracks in + if !tracks.isEmpty { + tracksToDelete = tracks + showDeleteConfirmation = true + } } ) } diff --git a/Music/MusicApp.swift b/Music/MusicApp.swift index e254fc2..0f97247 100644 --- a/Music/MusicApp.swift +++ b/Music/MusicApp.swift @@ -40,7 +40,23 @@ struct MusicApp: App { db: db, showNewPlaylistAlert: $showNewPlaylistAlert, showSmartPlaylistBuilder: $showSmartPlaylistBuilder, - networkStatus: computeNetworkStatus() + networkStatus: computeNetworkStatus(), + isHosting: hostServer?.isHosting ?? false, + onToggleHost: { + if hostServer?.isHosting ?? false { + hostServer?.stop() + } else { + startHosting() + } + }, + isStreaming: streamingServer?.isRunning ?? false, + onToggleStream: { + if streamingServer?.isRunning ?? false { + stopStreamingServer() + } else { + startStreamingServer() + } + } ) } else if let error = initError { Text("Failed to initialize database: \(error)") diff --git a/Music/Views/SearchBarView.swift b/Music/Views/SearchBarView.swift index 550091f..eac76ac 100644 --- a/Music/Views/SearchBarView.swift +++ b/Music/Views/SearchBarView.swift @@ -7,6 +7,10 @@ struct SearchBarView: View { let onSaveSearch: (String) -> Void let isShazamListening: Bool let onShazam: () -> Void + let isHosting: Bool + let onToggleHost: () -> Void + let isStreaming: Bool + let onToggleStream: () -> Void var body: some View { HStack(spacing: 12) { @@ -54,6 +58,22 @@ struct SearchBarView: View { } .buttonStyle(.plain) .help(isShazamListening ? "Stop listening" : "Identify song with Shazam") + + Button(action: onToggleHost) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 14)) + .foregroundStyle(isHosting ? .green : .secondary) + } + .buttonStyle(.plain) + .help(isHosting ? "Stop hosting" : "Start host mode") + + Button(action: onToggleStream) { + Image(systemName: "cloud") + .font(.system(size: 14)) + .foregroundStyle(isStreaming ? .purple : .secondary) + } + .buttonStyle(.plain) + .help(isStreaming ? "Stop streaming server" : "Start streaming server") } .padding(.horizontal, 16) .padding(.vertical, 8) diff --git a/Music/Views/TrackInfoSheet.swift b/Music/Views/TrackInfoSheet.swift index 4c0aa3e..f31411f 100644 --- a/Music/Views/TrackInfoSheet.swift +++ b/Music/Views/TrackInfoSheet.swift @@ -4,37 +4,51 @@ import SwiftUI // across tracks show a "Mixed" placeholder and only fields the user touches are // applied. onSave hands back the edited values + the set of edited fields. struct TrackInfoSheet: View { - let tracks: [Track] - var onSave: (EditableTrackFields, Set) -> Void - var onCancel: () -> Void + let allTracks: [Track] + var onSave: (EditableTrackFields, Set, [Track]) -> Void + var onDismiss: () -> Void + @State private var displayedTracks: [Track] @State private var fields: EditableTrackFields @State private var mixed: Set @State private var edited: Set = [] @State private var tab = 0 init(tracks: [Track], - onSave: @escaping (EditableTrackFields, Set) -> Void, - onCancel: @escaping () -> Void) { - self.tracks = tracks + allTracks: [Track], + onSave: @escaping (EditableTrackFields, Set, [Track]) -> Void, + onDismiss: @escaping () -> Void) { + self.allTracks = allTracks self.onSave = onSave - self.onCancel = onCancel + self.onDismiss = onDismiss + _displayedTracks = State(initialValue: tracks) let (values, mixed) = EditableTrackFields.shared(across: tracks) _fields = State(initialValue: values) _mixed = State(initialValue: mixed) } - private var isMulti: Bool { tracks.count > 1 } + private var isMulti: Bool { displayedTracks.count > 1 } private var hasUnsupported: Bool { - tracks.contains { t in + displayedTracks.contains { t in ["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased()) } } + // Navigation within allTracks (single-track mode only) + private var canNavigate: Bool { !isMulti && allTracks.count > 1 } + private var currentIndex: Int? { + guard let track = displayedTracks.first else { return nil } + return allTracks.firstIndex(where: { $0.id == track.id }) + } + private var hasPrevious: Bool { (currentIndex ?? 0) > 0 } + private var hasNext: Bool { + guard let idx = currentIndex else { return false } + return idx < allTracks.count - 1 + } + var body: some View { VStack(alignment: .leading, spacing: 16) { - Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info") - .font(.headline) + header if hasUnsupported { Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.") @@ -53,15 +67,62 @@ struct TrackInfoSheet: View { Divider() HStack { Spacer() - Button("Cancel", action: onCancel) - Button("Save") { onSave(fields, edited) } - .keyboardShortcut(.defaultAction) + Button("Cancel", action: onDismiss) + Button("Save") { + onSave(fields, edited, displayedTracks) + onDismiss() + } + .keyboardShortcut(.defaultAction) } } .padding(20) .frame(width: 460) } + @ViewBuilder private var header: some View { + HStack { + Text(isMulti ? "Get Info — \(displayedTracks.count) tracks" : "Get Info") + .font(.headline) + Spacer() + if canNavigate { + HStack(spacing: 2) { + Button(action: navigatePrevious) { + Image(systemName: "chevron.left") + } + .disabled(!hasPrevious) + + Button(action: navigateNext) { + Image(systemName: "chevron.right") + } + .disabled(!hasNext) + } + .buttonStyle(.borderless) + } + } + } + + private func navigatePrevious() { + guard let idx = currentIndex, idx > 0 else { return } + navigateTo(allTracks[idx - 1]) + } + + private func navigateNext() { + guard let idx = currentIndex, idx < allTracks.count - 1 else { return } + navigateTo(allTracks[idx + 1]) + } + + private func navigateTo(_ track: Track) { + if !edited.isEmpty { + onSave(fields, edited, displayedTracks) + } + displayedTracks = [track] + let (values, mixedSet) = EditableTrackFields.shared(across: [track]) + fields = values + mixed = mixedSet + edited = [] + tab = 0 + } + // Binding helper that marks a field edited whenever it changes. private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath) -> Binding { Binding( @@ -93,10 +154,16 @@ struct TrackInfoSheet: View { labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) } detailsNumericRow labeled("Rating") { - Stepper(value: Binding( - get: { fields.rating }, - set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) } - ), in: 0...5) { Text(String(repeating: "★", count: fields.rating)) } + HStack(spacing: 4) { + ForEach(1...5, id: \.self) { star in + Image(systemName: star <= fields.rating ? "star.fill" : "star") + .foregroundStyle(star <= fields.rating ? .yellow : .secondary) + .onTapGesture { + fields.rating = star == fields.rating ? 0 : star + edited.insert(.rating) + } + } + } } detailsDateRow } @@ -126,7 +193,7 @@ struct TrackInfoSheet: View { } @ViewBuilder private var fileTab: some View { - if let t = tracks.first { + if let t = displayedTracks.first { VStack(alignment: .leading, spacing: 6) { row("Kind", t.fileFormat.uppercased()) row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "—") diff --git a/Music/Views/TrackTableView.swift b/Music/Views/TrackTableView.swift index ce0aa4a..49c9386 100644 --- a/Music/Views/TrackTableView.swift +++ b/Music/Views/TrackTableView.swift @@ -46,6 +46,7 @@ struct TrackTableView: NSViewRepresentable { let onDoubleClick: (Track) -> Void var contextMenuConfig: TrackContextMenuConfig? var onReorder: ((Int, Int) -> Void)? + var onRatingChange: ((Track, Int) -> Void)? var scrollToPlayingTrigger: UUID = UUID() func makeNSView(context: Context) -> NSScrollView { @@ -197,6 +198,10 @@ struct TrackTableView: NSViewRepresentable { let track = tracks[row] let colId = tableColumn?.identifier.rawValue ?? "" + if colId == "rating" { + return ratingCell(for: tableView, row: row, track: track) + } + if colId == "nowPlaying" { let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying") let cellView: NSTableCellView @@ -286,8 +291,7 @@ struct TrackTableView: NSViewRepresentable { case "lastPlayedAt": cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? "" case "rating": - cell.stringValue = track.rating > 0 ? String(repeating: "★", count: track.rating) : "" - cell.alignment = .right + break case "dateAdded": cell.stringValue = Self.formatDate(track.dateAdded) case "dateModified": @@ -400,6 +404,23 @@ struct TrackTableView: NSViewRepresentable { return true } + private func ratingCell(for tableView: NSTableView, row: Int, track: Track) -> NSView { + let cellId = NSUserInterfaceItemIdentifier("Cell_rating") + let cellView: RatingCellView + if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? RatingCellView { + cellView = existing + } else { + cellView = RatingCellView() + cellView.identifier = cellId + } + cellView.rating = track.rating + cellView.onRatingChange = { [weak self] newRating in + guard let self, row < self.tracks.count else { return } + self.parent.onRatingChange?(self.tracks[row], newRating) + } + return cellView + } + @objc func toggleColumn(_ sender: NSMenuItem) { guard let colId = sender.representedObject as? String, let tableView else { return } guard let column = tableView.tableColumns.first(where: { $0.identifier.rawValue == colId }) else { return } @@ -437,6 +458,74 @@ struct TrackTableView: NSViewRepresentable { } } +private final class RatingCellView: NSTableCellView { + var onRatingChange: ((Int) -> Void)? + private var starButtons: [NSButton] = [] + + var rating: Int = 0 { + didSet { updateStars() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + let stack = NSStackView() + stack.orientation = .horizontal + stack.spacing = 1 + stack.translatesAutoresizingMaskIntoConstraints = false + + for i in 1...5 { + let button = NSButton() + button.bezelStyle = .inline + button.isBordered = false + button.image = NSImage(systemSymbolName: "star", accessibilityDescription: "Star \(i)") + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.tag = i + button.target = self + button.action = #selector(starClicked(_:)) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 14), + button.heightAnchor.constraint(equalToConstant: 14), + ]) + starButtons.append(button) + stack.addArrangedSubview(button) + } + + addSubview(stack) + NSLayoutConstraint.activate([ + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + private func updateStars() { + for button in starButtons { + let filled = button.tag <= rating + button.image = NSImage( + systemSymbolName: filled ? "star.fill" : "star", + accessibilityDescription: "Star \(button.tag)" + ) + button.contentTintColor = filled ? .systemYellow : .tertiaryLabelColor + } + } + + @objc private func starClicked(_ sender: NSButton) { + let newRating = sender.tag == rating ? 0 : sender.tag + rating = newRating + onRatingChange?(newRating) + } +} + private final class PlayableTableView: NSTableView { var enterAction: Selector?