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.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(

@ -1,39 +1,76 @@
import SwiftUI
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 {
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)
}
}
}

Loading…
Cancel
Save