feat: add track context menu and drag-to-reorder for playlists

feat/music-streaming
Laurent 1 month ago
parent a0b95681e2
commit 44854f9868
  1. 21
      Music/ContentView.swift
  2. 333
      Music/Views/TrackTableView.swift

@ -73,7 +73,26 @@ struct ContentView: View {
player.setQueue(trackList) player.setQueue(trackList)
player.play(track) 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( PlaylistBarView(

@ -1,39 +1,76 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
private let visibleColumnsKey = "visibleTrackColumns"
private let defaultVisibleColumnIds: Set<String> = ["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<String> {
if let saved = UserDefaults.standard.stringArray(forKey: visibleColumnsKey) {
return Set(saved).union(["title"])
}
return defaultVisibleColumnIds
}
struct TrackTableView: NSViewRepresentable { struct TrackTableView: NSViewRepresentable {
let tracks: [Track] let tracks: [Track]
let playingTrackId: Int64? let playingTrackId: Int64?
let onSort: (String) -> Void let onSort: (String) -> Void
let onDoubleClick: (Track) -> 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 { func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView() let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true scrollView.autohidesScrollers = true
let tableView = NSTableView() let tableView = PlayableTableView()
tableView.style = .plain tableView.style = .plain
tableView.usesAlternatingRowBackgroundColors = true tableView.usesAlternatingRowBackgroundColors = true
tableView.allowsMultipleSelection = false tableView.allowsMultipleSelection = false
tableView.rowHeight = 24 tableView.rowHeight = 24
tableView.intercellSpacing = NSSize(width: 10, height: 0) tableView.intercellSpacing = NSSize(width: 10, height: 0)
let columns: [(id: String, title: String, width: CGFloat)] = [ let visibleIds = loadVisibleColumnIds()
("title", "Title", 300),
("artist", "Artist", 200),
("album", "Album", 200),
("genre", "Genre", 100),
("duration", "Duration", 70),
]
for col in columns { for col in columnDefinitions {
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id)) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id))
column.title = col.title column.title = col.title
column.width = col.width column.width = col.width
column.minWidth = 50 column.minWidth = 50
column.sortDescriptorPrototype = NSSortDescriptor(key: col.id, ascending: true) 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 column.headerCell.alignment = .right
} }
tableView.addTableColumn(column) tableView.addTableColumn(column)
@ -43,8 +80,34 @@ struct TrackTableView: NSViewRepresentable {
tableView.dataSource = context.coordinator tableView.dataSource = context.coordinator
tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:)) tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:))
tableView.target = context.coordinator 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 scrollView.documentView = tableView
let rowMenu = NSMenu()
rowMenu.delegate = context.coordinator
tableView.menu = rowMenu
return scrollView return scrollView
} }
@ -56,6 +119,15 @@ struct TrackTableView: NSViewRepresentable {
context.coordinator.parent = self 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 } guard tracksChanged || playingChanged else { return }
let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in
@ -80,10 +152,11 @@ struct TrackTableView: NSViewRepresentable {
Coordinator(self) Coordinator(self)
} }
final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource { final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSMenuDelegate {
var parent: TrackTableView var parent: TrackTableView
var tracks: [Track] = [] var tracks: [Track] = []
var playingTrackId: Int64? var playingTrackId: Int64?
weak var tableView: NSTableView?
init(_ parent: TrackTableView) { init(_ parent: TrackTableView) {
self.parent = parent self.parent = parent
@ -99,46 +172,103 @@ struct TrackTableView: NSViewRepresentable {
let colId = tableColumn?.identifier.rawValue ?? "" let colId = tableColumn?.identifier.rawValue ?? ""
let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)") let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)")
let cell: NSTextField let cellView: NSTableCellView
if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField { if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView {
cell = existing cellView = existing
} else { } else {
cell = NSTextField(labelWithString: "") cellView = NSTableCellView()
cell.identifier = cellId cellView.identifier = cellId
cell.lineBreakMode = .byTruncatingTail let textField = NSTextField(labelWithString: "")
cell.font = .systemFont(ofSize: 12) 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 { switch colId {
case "title": case "title":
let isPlaying = track.id == parent.playingTrackId cell.stringValue = track.title
if isPlaying { cell.textColor = .labelColor
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)
}
case "artist": case "artist":
cell.stringValue = track.artist cell.stringValue = track.artist
cell.textColor = .secondaryLabelColor case "albumArtist":
cell.stringValue = track.albumArtist
case "album": case "album":
cell.stringValue = track.album cell.stringValue = track.album
cell.textColor = .tertiaryLabelColor cell.textColor = .tertiaryLabelColor
case "composer":
cell.stringValue = track.composer
case "genre": case "genre":
cell.stringValue = track.genre cell.stringValue = track.genre
cell.textColor = .tertiaryLabelColor 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": case "duration":
cell.stringValue = Self.formatDuration(track.duration) cell.stringValue = Self.formatDuration(track.duration)
cell.textColor = .tertiaryLabelColor cell.textColor = .tertiaryLabelColor
cell.alignment = .right 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: default:
cell.stringValue = "" cell.stringValue = ""
} }
return cell return cellView
} }
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
@ -153,11 +283,154 @@ struct TrackTableView: NSViewRepresentable {
parent.onDoubleClick(tracks[row]) 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 { static func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "0:00" } guard seconds.isFinite, seconds > 0 else { return "0:00" }
let mins = Int(seconds) / 60 let mins = Int(seconds) / 60
let secs = Int(seconds) % 60 let secs = Int(seconds) % 60
return "\(mins):\(String(format: "%02d", secs))" 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)
}
} }
} }

Loading…
Cancel
Save