From 11d5f91a86de8130776059cfc982d369fd472118 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 26 May 2026 12:31:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20improve=20player=20UX=20=E2=80=94=20scr?= =?UTF-8?q?ubbing,=20keyboard=20shortcuts,=20now-playing=20indicator,=20Ho?= =?UTF-8?q?me=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add chase-seek scrubbing for smooth slider interaction, global keyboard monitor for space/arrows, now-playing speaker column in track table, Home button in playlist bar, smart playlist sorting, and UI polish (label colors, track selection, app icon 512@2x). Bump version to 10. --- Music.xcodeproj/project.pbxproj | 4 +- .../AppIcon.appiconset/Contents.json | 2 +- .../AppIcon.appiconset/icon_mu.png | Bin 5932 -> 15170 bytes Music/ContentView.swift | 88 ++++++++++++----- Music/Services/AudioService.swift | 71 +++++++++++++- Music/ViewModels/PlaylistViewModel.swift | 12 +++ Music/Views/HomeView.swift | 12 +++ Music/Views/PlayerControlsView.swift | 37 ++++++- Music/Views/PlaylistBarView.swift | 81 ++++++++------- Music/Views/TrackTableView.swift | 92 ++++++++++++------ 10 files changed, 305 insertions(+), 94 deletions(-) diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index a8ed464..6f90dd4 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -426,7 +426,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -464,7 +464,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Music/Assets.xcassets/AppIcon.appiconset/Contents.json b/Music/Assets.xcassets/AppIcon.appiconset/Contents.json index f7c6368..e53c300 100644 --- a/Music/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Music/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -36,7 +36,6 @@ "size" : "256x256" }, { - "filename" : "icon_mu.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" @@ -47,6 +46,7 @@ "size" : "512x512" }, { + "filename" : "icon_mu.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png b/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png index d35328232f6ea84259bb4324e7a4d81019c6f940..96b39e883e65832d29ea6eee147040d0a72c0dfe 100644 GIT binary patch literal 15170 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 CDe1}p literal 5932 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 diff --git a/Music/ContentView.swift b/Music/ContentView.swift index f04988d..62dbdb5 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -18,7 +18,8 @@ struct ContentView: View { @State private var smartPlaylistToEdit: SmartPlaylist? @State private var scrollToPlayingTrigger = UUID() @State private var searchText = "" - @State private var showHome = true + @State private var keyMonitor: Any? + @State private var showHome = false @State private var recentTracks: [Track] = [] @State private var totalDuration: Double = 0 @State private var monthlyAdditions: [MonthlyCount] = [] @@ -29,11 +30,13 @@ struct ContentView: View { searchText: $searchText, trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount, onSearch: { text in - if text.isEmpty { - showHome = true - } else { + if !text.isEmpty { showHome = false } + if !text.isEmpty && library.searchText.isEmpty { + library.sortColumn = "album" + library.sortAscending = true + } library.search(text) if playlist.selectedPlaylist != nil { playlist.search(text) @@ -57,12 +60,12 @@ struct ContentView: View { .padding(.vertical, 4) } - if let selected = playlist.selectedItem { + if showHome || playlist.selectedItem != nil { HStack(spacing: 4) { Button(action: { playlist.deselectPlaylist() searchText = "" - showHome = true + showHome = false }) { HStack(spacing: 2) { Image(systemName: "chevron.left") @@ -78,7 +81,7 @@ struct ContentView: View { .font(.system(size: 12)) .foregroundStyle(.quaternary) - Text(selected.name) + Text(showHome ? "Home" : (playlist.selectedItem?.name ?? "")) .font(.system(size: 12, weight: .medium)) } .padding(.horizontal, 12) @@ -86,7 +89,7 @@ struct ContentView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if showHome && playlist.selectedItem == nil && searchText.isEmpty { + if showHome && playlist.selectedItem == nil { HomeView( recentTracks: recentTracks, trackCount: library.trackCount, @@ -105,10 +108,12 @@ struct ContentView: View { TrackTableView( tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks, playingTrackId: player.currentTrack?.id, - sortColumn: library.sortColumn, - sortAscending: library.sortAscending, + sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn, + sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending, onSort: { column in - if playlist.selectedItem == nil { + if playlist.selectedSmartPlaylist != nil { + playlist.sort(by: column) + } else if playlist.selectedItem == nil { library.sort(by: column) } }, @@ -117,7 +122,6 @@ struct ContentView: View { player.setQueue(trackList) player.play(track) }, - onPlayPause: { audio.togglePlayPause() }, playlists: playlist.playlists, lastUsedPlaylistName: playlist.lastUsedPlaylistName, selectedPlaylist: playlist.selectedPlaylist, @@ -143,8 +147,23 @@ struct ContentView: View { PlaylistBarView( playlists: playlist.allPlaylists, - selectedItem: playlist.selectedItem, + selectedItem: showHome ? nil : playlist.selectedItem, + isHomeSelected: showHome, + onHomeSelect: { + if showHome { + showHome = false + } else { + playlist.deselectPlaylist() + searchText = "" + showHome = true + } + }, onSelect: { item in + showHome = false + if item is SmartPlaylist { + playlist.sortColumn = "album" + playlist.sortAscending = true + } playlist.selectItem(item) if let smart = item as? SmartPlaylist { searchText = smart.searchQuery @@ -154,7 +173,6 @@ struct ContentView: View { onDeselect: { playlist.deselectPlaylist() searchText = "" - showHome = true }, onRename: { item in itemToRename = item @@ -177,14 +195,8 @@ struct ContentView: View { playerControls } - .onKeyPress(.leftArrow) { - player.previous() - return .handled - } - .onKeyPress(.rightArrow) { - player.next() - return .handled - } + .onAppear { installKeyboardMonitor() } + .onDisappear { removeKeyboardMonitor() } .onDrop(of: [.fileURL], isTargeted: nil) { providers in handleDrop(providers) return true @@ -281,6 +293,38 @@ struct ContentView: View { ) } + private func installKeyboardMonitor() { + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player] event in + guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else { + return event + } + guard let responder = NSApp.keyWindow?.firstResponder, + !(responder is NSTextView) else { + return event + } + switch event.keyCode { + case 49: // space + audio.togglePlayPause() + return nil + case 123: // left arrow + player.previous() + return nil + case 124: // right arrow + player.next() + return nil + default: + return event + } + } + } + + private func removeKeyboardMonitor() { + if let monitor = keyMonitor { + NSEvent.removeMonitor(monitor) + keyMonitor = nil + } + } + private func loadHomeData() { recentTracks = (try? db.fetchRecentlyAdded(limit: 50)) ?? [] totalDuration = (try? db.totalDuration()) ?? 0 diff --git a/Music/Services/AudioService.swift b/Music/Services/AudioService.swift index 000059b..6a00bee 100644 --- a/Music/Services/AudioService.swift +++ b/Music/Services/AudioService.swift @@ -20,6 +20,10 @@ final class AudioService { private var timeObserver: Any? private var endObserver: NSObjectProtocol? + private(set) var isScrubbing = false + private var seekInProgress = false + private var pendingSeekTime: Double? + var onTrackFinished: (() -> Void)? func play(url: URL) { @@ -33,7 +37,7 @@ final class AudioService { forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in - guard let self else { return } + guard let self, !self.isScrubbing else { return } self.currentTime = time.seconds if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { self.duration = dur.seconds @@ -69,7 +73,70 @@ final class AudioService { } func seek(to time: Double) { - player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) + let clamped = clampedTime(time) + currentTime = clamped + player?.seek( + to: CMTime(seconds: clamped, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + func beginScrubbing() { + isScrubbing = true + } + + func scrub(to time: Double) { + let clamped = clampedTime(time) + currentTime = clamped + chaseSeek(to: clamped) + } + + func endScrubbing(at time: Double) { + let clamped = clampedTime(time) + currentTime = clamped + pendingSeekTime = nil + seekInProgress = false + + player?.seek( + to: CMTime(seconds: clamped, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self] _ in + DispatchQueue.main.async { + self?.isScrubbing = false + } + } + } + + private func chaseSeek(to time: Double) { + pendingSeekTime = time + guard !seekInProgress else { return } + performPendingSeek() + } + + private func performPendingSeek() { + guard let time = pendingSeekTime else { return } + pendingSeekTime = nil + seekInProgress = true + + player?.seek( + to: CMTime(seconds: time, preferredTimescale: 600), + toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600), + toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600) + ) { [weak self] _ in + DispatchQueue.main.async { + guard let self else { return } + self.seekInProgress = false + if self.pendingSeekTime != nil { + self.performPendingSeek() + } + } + } + } + + private func clampedTime(_ time: Double) -> Double { + max(0, min(time, duration)) } func stop() { diff --git a/Music/ViewModels/PlaylistViewModel.swift b/Music/ViewModels/PlaylistViewModel.swift index 5a7f9ac..36186dd 100644 --- a/Music/ViewModels/PlaylistViewModel.swift +++ b/Music/ViewModels/PlaylistViewModel.swift @@ -142,6 +142,18 @@ final class PlaylistViewModel { searchText = "" } + func sort(by column: String) { + if sortColumn == column { + sortAscending.toggle() + } else { + sortColumn = column + sortAscending = true + } + if let smart = selectedSmartPlaylist { + observeSmartPlaylistTracks(searchQuery: smart.searchQuery) + } + } + func search(_ text: String) { searchText = text searchTask?.cancel() diff --git a/Music/Views/HomeView.swift b/Music/Views/HomeView.swift index 6252342..1b3f221 100644 --- a/Music/Views/HomeView.swift +++ b/Music/Views/HomeView.swift @@ -9,6 +9,8 @@ struct HomeView: View { let onTrackDoubleClick: (Track) -> Void let onShowAll: () -> Void + @State private var selectedTrack: Track? + var body: some View { HStack(alignment: .top, spacing: 0) { recentlyAddedPanel @@ -19,6 +21,7 @@ struct HomeView: View { statsPanel .frame(minWidth: 300, maxWidth: 300, maxHeight: .infinity) } + .background(.white) } private var recentlyAddedPanel: some View { @@ -50,10 +53,19 @@ struct HomeView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) .padding(.horizontal, 16) + .background( + selectedTrack == track + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) .onTapGesture(count: 2) { onTrackDoubleClick(track) } + .simultaneousGesture(TapGesture().onEnded { + selectedTrack = track + }) } } } diff --git a/Music/Views/PlayerControlsView.swift b/Music/Views/PlayerControlsView.swift index 494416e..a9ebd1f 100644 --- a/Music/Views/PlayerControlsView.swift +++ b/Music/Views/PlayerControlsView.swift @@ -11,8 +11,15 @@ struct PlayerControlsView: View { let onNext: () -> Void let onPrevious: () -> Void let onSeek: (Double) -> Void + let onScrubStart: () -> Void + let onScrub: (Double) -> Void + let onScrubEnd: (Double) -> Void let onVolumeChange: (Float) -> Void let onShuffleToggle: () -> Void + let onNowPlayingTap: () -> Void + + @State private var isDragging = false + @State private var dragValue: Double = 0 var body: some View { HStack(spacing: 0) { @@ -60,6 +67,12 @@ struct PlayerControlsView: View { } } } + .contentShape(Rectangle()) + .onTapGesture { + if currentTrack != nil { + onNowPlayingTap() + } + } } private var transportSection: some View { @@ -81,6 +94,7 @@ struct PlayerControlsView: View { Button(action: onPlayPause) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 22)) + .frame(width: 24, height: 24) } .buttonStyle(.plain) @@ -95,17 +109,32 @@ struct PlayerControlsView: View { } HStack(spacing: 8) { - Text(Self.formatTime(currentTime)) + Text(Self.formatTime(isDragging ? dragValue : currentTime)) .font(.system(size: 10).monospacedDigit()) .foregroundStyle(.secondary) .frame(width: 45, alignment: .trailing) Slider( value: Binding( - get: { currentTime }, - set: { onSeek($0) } + get: { isDragging ? dragValue : currentTime }, + set: { newValue in + dragValue = newValue + if isDragging { + onScrub(newValue) + } + } ), - in: 0...max(duration, 1) + in: 0...max(duration, 1), + onEditingChanged: { editing in + if editing { + isDragging = true + dragValue = currentTime + onScrubStart() + } else { + onScrubEnd(dragValue) + isDragging = false + } + } ) .controlSize(.small) diff --git a/Music/Views/PlaylistBarView.swift b/Music/Views/PlaylistBarView.swift index f74ddcd..91e7476 100644 --- a/Music/Views/PlaylistBarView.swift +++ b/Music/Views/PlaylistBarView.swift @@ -3,6 +3,8 @@ import SwiftUI struct PlaylistBarView: View { var playlists: [any PlaylistRepresentable] var selectedItem: (any PlaylistRepresentable)? + var isHomeSelected: Bool + var onHomeSelect: () -> Void var onSelect: (any PlaylistRepresentable) -> Void var onDeselect: () -> Void var onRename: (any PlaylistRepresentable) -> Void @@ -10,33 +12,39 @@ struct PlaylistBarView: View { var onEditQuery: (SmartPlaylist) -> Void var body: some View { - if !playlists.isEmpty { - FlowLayout(spacing: 6) { - ForEach(playlists, id: \.id) { item in - PlaylistButton( - name: item.name, - isSelected: selectedItem?.id == item.id, - isSmart: item.isSmartPlaylist, - action: { - if selectedItem?.id == item.id { - onDeselect() - } else { - onSelect(item) - } - } - ) - .contextMenu { - Button("Rename...") { onRename(item) } - if let smart = item as? SmartPlaylist { - Button("Edit Search Query...") { onEditQuery(smart) } + FlowLayout(spacing: 6) { + PlaylistButton( + name: "Home", + isSelected: isHomeSelected, + isSmart: false, + icon: "house.fill", + action: onHomeSelect + ) + + ForEach(playlists, id: \.id) { item in + PlaylistButton( + name: item.name, + isSelected: selectedItem?.id == item.id, + isSmart: item.isSmartPlaylist, + action: { + if selectedItem?.id == item.id { + onDeselect() + } else { + onSelect(item) } - Button("Delete") { onDelete(item) } } + ) + .contextMenu { + Button("Rename...") { onRename(item) } + if let smart = item as? SmartPlaylist { + Button("Edit Search Query...") { onEditQuery(smart) } + } + Button("Delete") { onDelete(item) } } } - .padding(.horizontal, 12) - .padding(.vertical, 6) } + .padding(.horizontal, 12) + .padding(.vertical, 6) } } @@ -44,6 +52,7 @@ private struct PlaylistButton: View { let name: String let isSelected: Bool let isSmart: Bool + var icon: String? = nil let action: () -> Void private var tintColor: Color { @@ -56,17 +65,23 @@ private struct PlaylistButton: View { var body: some View { Button(action: action) { - Text(name) - .font(.system(size: 11)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1)) - .foregroundStyle(isSelected ? tintColor : inactiveColor) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1) - ) - .cornerRadius(4) + HStack(spacing: 4) { + if let icon { + Image(systemName: icon) + .font(.system(size: 10)) + } + Text(name) + } + .font(.system(size: 11)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundStyle(isSelected ? tintColor : inactiveColor) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(4) } .buttonStyle(.plain) } diff --git a/Music/Views/TrackTableView.swift b/Music/Views/TrackTableView.swift index c663a7f..ecb919a 100644 --- a/Music/Views/TrackTableView.swift +++ b/Music/Views/TrackTableView.swift @@ -38,9 +38,10 @@ private func loadVisibleColumnIds() -> Set { struct TrackTableView: NSViewRepresentable { let tracks: [Track] let playingTrackId: Int64? + let sortColumn: String + let sortAscending: Bool let onSort: (String) -> Void let onDoubleClick: (Track) -> Void - let onPlayPause: () -> Void var playlists: [Playlist] var lastUsedPlaylistName: String? var selectedPlaylist: Playlist? @@ -48,6 +49,7 @@ struct TrackTableView: NSViewRepresentable { var onAddToLastPlaylist: ((Track) -> Void)? var onRemoveFromPlaylist: ((Track) -> Void)? var onReorder: ((Int, Int) -> Void)? + var scrollToPlayingTrigger: UUID = UUID() func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() @@ -63,6 +65,13 @@ struct TrackTableView: NSViewRepresentable { let visibleIds = loadVisibleColumnIds() + let nowPlayingColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("nowPlaying")) + nowPlayingColumn.title = "" + nowPlayingColumn.width = 20 + nowPlayingColumn.minWidth = 20 + nowPlayingColumn.maxWidth = 20 + tableView.addTableColumn(nowPlayingColumn) + for col in columnDefinitions { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id)) column.title = col.title @@ -76,12 +85,13 @@ struct TrackTableView: NSViewRepresentable { tableView.addTableColumn(column) } + tableView.sortDescriptors = [NSSortDescriptor(key: sortColumn, ascending: sortAscending)] + tableView.delegate = context.coordinator 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 @@ -116,9 +126,15 @@ struct TrackTableView: NSViewRepresentable { let tracksChanged = context.coordinator.tracks != tracks let playingChanged = context.coordinator.playingTrackId != playingTrackId + let scrollTriggered = context.coordinator.lastScrollTrigger != scrollToPlayingTrigger context.coordinator.parent = self + let expectedDescriptor = NSSortDescriptor(key: sortColumn, ascending: sortAscending) + if tableView.sortDescriptors.first != expectedDescriptor { + tableView.sortDescriptors = [expectedDescriptor] + } + if context.coordinator.parent.onReorder != nil { if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { tableView.registerForDraggedTypes([.string]) @@ -128,6 +144,15 @@ struct TrackTableView: NSViewRepresentable { tableView.unregisterDraggedTypes() } + if scrollTriggered { + context.coordinator.lastScrollTrigger = scrollToPlayingTrigger + if let playingId = playingTrackId, + let row = context.coordinator.tracks.firstIndex(where: { $0.id == playingId }) { + tableView.scrollRowToVisible(row) + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + } + guard tracksChanged || playingChanged else { return } let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in @@ -156,6 +181,7 @@ struct TrackTableView: NSViewRepresentable { var parent: TrackTableView var tracks: [Track] = [] var playingTrackId: Int64? + var lastScrollTrigger: UUID = UUID() weak var tableView: NSTableView? init(_ parent: TrackTableView) { @@ -171,6 +197,36 @@ struct TrackTableView: NSViewRepresentable { let track = tracks[row] let colId = tableColumn?.identifier.rawValue ?? "" + if colId == "nowPlaying" { + let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying") + let cellView: NSTableCellView + if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView { + cellView = existing + } else { + cellView = NSTableCellView() + cellView.identifier = cellId + let imageView = NSImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.imageScaling = .scaleProportionallyDown + cellView.addSubview(imageView) + cellView.imageView = imageView + NSLayoutConstraint.activate([ + imageView.centerXAnchor.constraint(equalTo: cellView.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: 12), + imageView.heightAnchor.constraint(equalToConstant: 12), + ]) + } + let isPlaying = track.id == parent.playingTrackId + if isPlaying { + cellView.imageView?.image = NSImage(systemSymbolName: "speaker.fill", accessibilityDescription: "Now Playing") + cellView.imageView?.contentTintColor = .secondaryLabelColor + } else { + cellView.imageView?.image = nil + } + return cellView + } + let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)") let cellView: NSTableCellView if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView { @@ -193,76 +249,59 @@ struct TrackTableView: NSViewRepresentable { let cell = cellView.textField! let isPlaying = track.id == parent.playingTrackId cell.font = isPlaying ? .boldSystemFont(ofSize: 12) : .systemFont(ofSize: 12) - cell.textColor = .secondaryLabelColor + cell.textColor = .labelColor cell.alignment = .left switch colId { case "title": cell.stringValue = track.title - cell.textColor = .labelColor case "artist": cell.stringValue = track.artist 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 = "" @@ -272,9 +311,9 @@ struct TrackTableView: NSViewRepresentable { } func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - if let sort = tableView.sortDescriptors.first, let key = sort.key { - parent.onSort(key) - } + guard let sort = tableView.sortDescriptors.first, let key = sort.key else { return } + guard key != parent.sortColumn || sort.ascending != parent.sortAscending else { return } + parent.onSort(key) } @objc func handleDoubleClick(_ sender: NSTableView) { @@ -289,10 +328,6 @@ struct TrackTableView: NSViewRepresentable { parent.onDoubleClick(tracks[row]) } - @objc func handleSpaceKey(_ sender: NSTableView) { - parent.onPlayPause() - } - // MARK: - Context Menu func menuNeedsUpdate(_ menu: NSMenu) { @@ -422,13 +457,10 @@ struct TrackTableView: NSViewRepresentable { 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) }