|
|
|
@ -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) |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|