From c374cfa9eb8fa0865839b68e98419c95c27d3e8a Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 23 May 2026 23:56:25 +0200 Subject: [PATCH] feat: add TrackTableView with NSTableView for high-performance track list --- Music/Views/TrackTableView.swift | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 Music/Views/TrackTableView.swift diff --git a/Music/Views/TrackTableView.swift b/Music/Views/TrackTableView.swift new file mode 100644 index 0000000..2a1f79a --- /dev/null +++ b/Music/Views/TrackTableView.swift @@ -0,0 +1,163 @@ +import SwiftUI +import AppKit + +struct TrackTableView: NSViewRepresentable { + let tracks: [Track] + let playingTrackId: Int64? + let onSort: (String) -> Void + let onDoubleClick: (Track) -> Void + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + let tableView = NSTableView() + 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), + ] + + for col in columns { + 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.headerCell.alignment = .right + } + tableView.addTableColumn(column) + } + + tableView.delegate = context.coordinator + tableView.dataSource = context.coordinator + tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:)) + tableView.target = context.coordinator + + scrollView.documentView = tableView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let tableView = scrollView.documentView as? NSTableView else { return } + + let tracksChanged = context.coordinator.tracks != tracks + let playingChanged = context.coordinator.playingTrackId != playingTrackId + + context.coordinator.parent = self + + guard tracksChanged || playingChanged else { return } + + let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in + guard idx < context.coordinator.tracks.count else { return nil } + return context.coordinator.tracks[idx].id + }) + + context.coordinator.tracks = tracks + context.coordinator.playingTrackId = playingTrackId + + tableView.reloadData() + + if !selectedIds.isEmpty { + let newSelection = IndexSet(tracks.enumerated().compactMap { index, track in + selectedIds.contains(track.id ?? -1) ? index : nil + }) + tableView.selectRowIndexes(newSelection, byExtendingSelection: false) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource { + var parent: TrackTableView + var tracks: [Track] = [] + var playingTrackId: Int64? + + init(_ parent: TrackTableView) { + self.parent = parent + } + + func numberOfRows(in tableView: NSTableView) -> Int { + tracks.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row < tracks.count else { return nil } + let track = tracks[row] + 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 + } else { + cell = NSTextField(labelWithString: "") + cell.identifier = cellId + cell.lineBreakMode = .byTruncatingTail + cell.font = .systemFont(ofSize: 12) + } + + 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) + } + case "artist": + cell.stringValue = track.artist + cell.textColor = .secondaryLabelColor + case "album": + cell.stringValue = track.album + cell.textColor = .tertiaryLabelColor + case "genre": + cell.stringValue = track.genre + cell.textColor = .tertiaryLabelColor + case "duration": + cell.stringValue = Self.formatDuration(track.duration) + cell.textColor = .tertiaryLabelColor + cell.alignment = .right + default: + cell.stringValue = "" + } + + return cell + } + + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + if let sort = tableView.sortDescriptors.first, let key = sort.key { + parent.onSort(key) + } + } + + @objc func handleDoubleClick(_ sender: NSTableView) { + let row = sender.clickedRow + guard row >= 0, row < tracks.count else { return } + parent.onDoubleClick(tracks[row]) + } + + 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))" + } + } +}