diff --git a/Music/ContentView.swift b/Music/ContentView.swift index da3a1cf..5490aff 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -73,7 +73,26 @@ struct ContentView: View { player.setQueue(trackList) player.play(track) }, - onPlayPause: { audio.togglePlayPause() } + onPlayPause: { audio.togglePlayPause() }, + playlists: playlist.playlists, + lastUsedPlaylistName: playlist.lastUsedPlaylistName, + selectedPlaylist: playlist.selectedPlaylist, + onAddToPlaylist: { track, targetPlaylist in + try? playlist.addTrack(track, to: targetPlaylist) + }, + onAddToLastPlaylist: { track in + try? playlist.addTrackToLastUsedPlaylist(track) + }, + onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in + if let selected = playlist.selectedPlaylist { + try? playlist.removeTrack(track, from: selected) + } + } : nil, + onReorder: playlist.selectedPlaylist != nil ? { from, to in + if let selected = playlist.selectedPlaylist { + try? playlist.moveTrack(in: selected, from: from, to: to) + } + } : nil ) PlaylistBarView( diff --git a/Music/Views/TrackTableView.swift b/Music/Views/TrackTableView.swift index 2a1f79a..c663a7f 100644 --- a/Music/Views/TrackTableView.swift +++ b/Music/Views/TrackTableView.swift @@ -1,39 +1,76 @@ import SwiftUI import AppKit +private let visibleColumnsKey = "visibleTrackColumns" + +private let defaultVisibleColumnIds: Set = ["title", "artist", "album", "genre", "duration"] + +private let columnDefinitions: [(id: String, title: String, width: CGFloat, rightAlign: Bool)] = [ + ("title", "Title", 300, false), + ("artist", "Artist", 200, false), + ("albumArtist", "Album Artist", 200, false), + ("album", "Album", 200, false), + ("composer", "Composer", 150, false), + ("genre", "Genre", 100, false), + ("year", "Year", 50, true), + ("bpm", "BPM", 50, true), + ("trackNumber", "Track #", 50, true), + ("discNumber", "Disc #", 50, true), + ("duration", "Time", 70, true), + ("playCount", "Plays", 50, true), + ("lastPlayedAt", "Last Played", 130, false), + ("rating", "Rating", 60, true), + ("dateAdded", "Date Added", 130, false), + ("dateModified", "Date Modified", 130, false), + ("fileFormat", "Kind", 60, false), + ("bitrate", "Bit Rate", 80, true), + ("sampleRate", "Sample Rate", 90, true), + ("fileSize", "Size", 70, true), +] + +private func loadVisibleColumnIds() -> Set { + if let saved = UserDefaults.standard.stringArray(forKey: visibleColumnsKey) { + return Set(saved).union(["title"]) + } + return defaultVisibleColumnIds +} + struct TrackTableView: NSViewRepresentable { let tracks: [Track] let playingTrackId: Int64? let onSort: (String) -> Void let onDoubleClick: (Track) -> Void + let onPlayPause: () -> Void + var playlists: [Playlist] + var lastUsedPlaylistName: String? + var selectedPlaylist: Playlist? + var onAddToPlaylist: ((Track, Playlist) -> Void)? + var onAddToLastPlaylist: ((Track) -> Void)? + var onRemoveFromPlaylist: ((Track) -> Void)? + var onReorder: ((Int, Int) -> Void)? func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true - let tableView = NSTableView() + let tableView = PlayableTableView() tableView.style = .plain tableView.usesAlternatingRowBackgroundColors = true tableView.allowsMultipleSelection = false tableView.rowHeight = 24 tableView.intercellSpacing = NSSize(width: 10, height: 0) - let columns: [(id: String, title: String, width: CGFloat)] = [ - ("title", "Title", 300), - ("artist", "Artist", 200), - ("album", "Album", 200), - ("genre", "Genre", 100), - ("duration", "Duration", 70), - ] + let visibleIds = loadVisibleColumnIds() - for col in columns { + for col in columnDefinitions { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id)) column.title = col.title column.width = col.width column.minWidth = 50 column.sortDescriptorPrototype = NSSortDescriptor(key: col.id, ascending: true) - if col.id == "duration" { + column.isHidden = !visibleIds.contains(col.id) + if col.rightAlign { column.headerCell.alignment = .right } tableView.addTableColumn(column) @@ -43,8 +80,34 @@ struct TrackTableView: NSViewRepresentable { tableView.dataSource = context.coordinator tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:)) tableView.target = context.coordinator + tableView.enterAction = #selector(Coordinator.handleEnterKey(_:)) + tableView.spaceAction = #selector(Coordinator.handleSpaceKey(_:)) + + context.coordinator.tableView = tableView + + let menu = NSMenu() + for col in columnDefinitions { + let item = NSMenuItem( + title: col.title, + action: #selector(Coordinator.toggleColumn(_:)), + keyEquivalent: "" + ) + item.target = context.coordinator + item.representedObject = col.id + item.state = visibleIds.contains(col.id) ? .on : .off + if col.id == "title" { + item.isEnabled = false + } + menu.addItem(item) + } + tableView.headerView?.menu = menu scrollView.documentView = tableView + + let rowMenu = NSMenu() + rowMenu.delegate = context.coordinator + tableView.menu = rowMenu + return scrollView } @@ -56,6 +119,15 @@ struct TrackTableView: NSViewRepresentable { context.coordinator.parent = self + if context.coordinator.parent.onReorder != nil { + if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { + tableView.registerForDraggedTypes([.string]) + tableView.draggingDestinationFeedbackStyle = .gap + } + } else { + tableView.unregisterDraggedTypes() + } + guard tracksChanged || playingChanged else { return } let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in @@ -80,10 +152,11 @@ struct TrackTableView: NSViewRepresentable { Coordinator(self) } - final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource { + final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSMenuDelegate { var parent: TrackTableView var tracks: [Track] = [] var playingTrackId: Int64? + weak var tableView: NSTableView? init(_ parent: TrackTableView) { self.parent = parent @@ -99,46 +172,103 @@ struct TrackTableView: NSViewRepresentable { let colId = tableColumn?.identifier.rawValue ?? "" let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)") - let cell: NSTextField - if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField { - cell = existing + let cellView: NSTableCellView + if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView { + cellView = existing } else { - cell = NSTextField(labelWithString: "") - cell.identifier = cellId - cell.lineBreakMode = .byTruncatingTail - cell.font = .systemFont(ofSize: 12) + cellView = NSTableCellView() + cellView.identifier = cellId + let textField = NSTextField(labelWithString: "") + textField.lineBreakMode = .byTruncatingTail + textField.font = .systemFont(ofSize: 12) + textField.translatesAutoresizingMaskIntoConstraints = false + cellView.addSubview(textField) + cellView.textField = textField + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cellView.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), + textField.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) } + let cell = cellView.textField! + let isPlaying = track.id == parent.playingTrackId + cell.font = isPlaying ? .boldSystemFont(ofSize: 12) : .systemFont(ofSize: 12) + cell.textColor = .secondaryLabelColor + cell.alignment = .left switch colId { case "title": - let isPlaying = track.id == parent.playingTrackId - if isPlaying { - cell.stringValue = "▶ \(track.title)" - cell.textColor = .systemBlue - cell.font = .systemFont(ofSize: 12, weight: .medium) - } else { - cell.stringValue = track.title - cell.textColor = .labelColor - cell.font = .systemFont(ofSize: 12) - } + cell.stringValue = track.title + cell.textColor = .labelColor case "artist": cell.stringValue = track.artist - cell.textColor = .secondaryLabelColor + case "albumArtist": + cell.stringValue = track.albumArtist case "album": cell.stringValue = track.album cell.textColor = .tertiaryLabelColor + case "composer": + cell.stringValue = track.composer case "genre": cell.stringValue = track.genre cell.textColor = .tertiaryLabelColor + case "year": + cell.stringValue = track.year.map { String($0) } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "bpm": + cell.stringValue = track.bpm.map { String($0) } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "trackNumber": + cell.stringValue = track.trackNumber.map { String($0) } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "discNumber": + cell.stringValue = track.discNumber.map { String($0) } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right case "duration": cell.stringValue = Self.formatDuration(track.duration) cell.textColor = .tertiaryLabelColor cell.alignment = .right + case "playCount": + cell.stringValue = track.playCount > 0 ? "\(track.playCount)" : "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "lastPlayedAt": + cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? "" + cell.textColor = .tertiaryLabelColor + case "rating": + cell.stringValue = track.rating > 0 ? String(repeating: "★", count: track.rating) : "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "dateAdded": + cell.stringValue = Self.formatDate(track.dateAdded) + cell.textColor = .tertiaryLabelColor + case "dateModified": + cell.stringValue = Self.formatDate(track.dateModified) + cell.textColor = .tertiaryLabelColor + case "fileFormat": + cell.stringValue = track.fileFormat.uppercased() + cell.textColor = .tertiaryLabelColor + case "bitrate": + cell.stringValue = track.bitrate.map { "\($0) kbps" } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "sampleRate": + cell.stringValue = track.sampleRate.map { Self.formatSampleRate($0) } ?? "" + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + case "fileSize": + cell.stringValue = Self.formatFileSize(track.fileSize) + cell.textColor = .tertiaryLabelColor + cell.alignment = .right default: cell.stringValue = "" } - return cell + return cellView } func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { @@ -153,11 +283,154 @@ struct TrackTableView: NSViewRepresentable { parent.onDoubleClick(tracks[row]) } + @objc func handleEnterKey(_ sender: NSTableView) { + let row = sender.selectedRow + guard row >= 0, row < tracks.count else { return } + parent.onDoubleClick(tracks[row]) + } + + @objc func handleSpaceKey(_ sender: NSTableView) { + parent.onPlayPause() + } + + // MARK: - Context Menu + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } + + if let lastPlaylistName = parent.lastUsedPlaylistName, parent.onAddToLastPlaylist != nil { + let lastItem = NSMenuItem( + title: "Add to \(lastPlaylistName)", + action: #selector(addToLastPlaylist(_:)), + keyEquivalent: "" + ) + lastItem.target = self + menu.addItem(lastItem) + menu.addItem(.separator()) + } + + if !parent.playlists.isEmpty { + let submenu = NSMenu() + for (index, playlist) in parent.playlists.enumerated() { + let item = NSMenuItem( + title: playlist.name, + action: #selector(addToPlaylist(_:)), + keyEquivalent: "" + ) + item.target = self + item.tag = index + submenu.addItem(item) + } + let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + menu.addItem(submenuItem) + } + + if parent.selectedPlaylist != nil, parent.onRemoveFromPlaylist != nil { + menu.addItem(.separator()) + let removeItem = NSMenuItem( + title: "Remove from Playlist", + action: #selector(removeFromPlaylist(_:)), + keyEquivalent: "" + ) + removeItem.target = self + menu.addItem(removeItem) + } + } + + @objc func addToPlaylist(_ sender: NSMenuItem) { + guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } + let track = tracks[tableView.clickedRow] + let playlist = parent.playlists[sender.tag] + parent.onAddToPlaylist?(track, playlist) + } + + @objc func addToLastPlaylist(_ sender: NSMenuItem) { + guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } + let track = tracks[tableView.clickedRow] + parent.onAddToLastPlaylist?(track) + } + + @objc func removeFromPlaylist(_ sender: NSMenuItem) { + guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } + let track = tracks[tableView.clickedRow] + parent.onRemoveFromPlaylist?(track) + } + + // MARK: - Drag and Drop + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard parent.onReorder != nil else { return nil } + return "\(row)" as NSString + } + + func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { + guard parent.onReorder != nil, dropOperation == .above else { return [] } + return .move + } + + func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { + guard let onReorder = parent.onReorder else { return false } + guard let item = info.draggingPasteboard.pasteboardItems?.first, + let rowString = item.string(forType: .string), + let sourceRow = Int(rowString) else { return false } + + let destination = sourceRow < row ? row - 1 : row + guard sourceRow != destination else { return false } + + onReorder(sourceRow, destination) + return true + } + + @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 } + + column.isHidden.toggle() + sender.state = column.isHidden ? .off : .on + + let visibleIds = tableView.tableColumns + .filter { !$0.isHidden } + .map { $0.identifier.rawValue } + UserDefaults.standard.set(visibleIds, forKey: visibleColumnsKey) + } + static func formatDuration(_ seconds: Double) -> String { guard seconds.isFinite, seconds > 0 else { return "0:00" } let mins = Int(seconds) / 60 let secs = Int(seconds) % 60 return "\(mins):\(String(format: "%02d", secs))" } + + static func formatSampleRate(_ rate: Int) -> String { + if rate % 1000 == 0 { + return "\(rate / 1000) kHz" + } + return String(format: "%.1f kHz", Double(rate) / 1000.0) + } + + static func formatFileSize(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + static func formatDate(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .omitted) + } + } +} + +private final class PlayableTableView: NSTableView { + var enterAction: Selector? + var spaceAction: Selector? + + override func keyDown(with event: NSEvent) { + if event.keyCode == 36 || event.keyCode == 76, let enterAction, let target { + NSApp.sendAction(enterAction, to: target, from: self) + } else if event.keyCode == 49, let spaceAction, let target { + NSApp.sendAction(spaceAction, to: target, from: self) + } else { + super.keyDown(with: event) + } } }