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 d353282..96b39e8 100644 Binary files a/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png and b/Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png differ 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) }