improvements

main
Laurent 1 month ago
parent 7f31945b84
commit 771c38f0a5
  1. 4
      Music.xcodeproj/project.pbxproj
  2. 57
      Music/ContentView.swift
  3. 18
      Music/MusicApp.swift
  4. 20
      Music/Views/SearchBarView.swift
  5. 105
      Music/Views/TrackInfoSheet.swift
  6. 93
      Music/Views/TrackTableView.swift

@ -447,7 +447,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP; DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@ -494,7 +494,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP; DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;

@ -6,6 +6,7 @@ import UniformTypeIdentifiers
struct TrackInfoRequest: Identifiable { struct TrackInfoRequest: Identifiable {
let id = UUID() let id = UUID()
let tracks: [Track] let tracks: [Track]
let allTracks: [Track]
} }
struct ContentView: View { struct ContentView: View {
@ -18,6 +19,10 @@ struct ContentView: View {
@Binding var showNewPlaylistAlert: Bool @Binding var showNewPlaylistAlert: Bool
@Binding var showSmartPlaylistBuilder: Bool @Binding var showSmartPlaylistBuilder: Bool
var networkStatus: NetworkStatus? var networkStatus: NetworkStatus?
var isHosting: Bool
var onToggleHost: () -> Void
var isStreaming: Bool
var onToggleStream: () -> Void
@State private var infoRequest: TrackInfoRequest? @State private var infoRequest: TrackInfoRequest?
@State private var showRenameAlert = false @State private var showRenameAlert = false
@State private var showEditQueryAlert = false @State private var showEditQueryAlert = false
@ -28,6 +33,8 @@ struct ContentView: View {
@State private var editQueryInput = "" @State private var editQueryInput = ""
@State private var itemToRename: (any PlaylistRepresentable)? @State private var itemToRename: (any PlaylistRepresentable)?
@State private var smartPlaylistToEdit: SmartPlaylist? @State private var smartPlaylistToEdit: SmartPlaylist?
@State private var tracksToDelete: [Track] = []
@State private var showDeleteConfirmation = false
@State private var scrollToPlayingTrigger = UUID() @State private var scrollToPlayingTrigger = UUID()
@State private var searchText = "" @State private var searchText = ""
@State private var keyMonitor: Any? @State private var keyMonitor: Any?
@ -112,7 +119,11 @@ struct ContentView: View {
try? playlist.createSmartPlaylist(searchQuery: query) try? playlist.createSmartPlaylist(searchQuery: query)
}, },
isShazamListening: shazam.isListening, isShazamListening: shazam.isListening,
onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() } onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() },
isHosting: isHosting,
onToggleHost: onToggleHost,
isStreaming: isStreaming,
onToggleStream: onToggleStream
) )
if scanner.isScanning { if scanner.isScanning {
@ -196,6 +207,13 @@ struct ContentView: View {
try? playlist.moveTrack(in: selected, from: from, to: to) try? playlist.moveTrack(in: selected, from: from, to: to)
} }
} : nil, } : nil,
onRatingChange: { track, newRating in
var fields = EditableTrackFields(from: track)
fields.rating = newRating
Task {
_ = await library.applyTrackEdits(fields, editing: [.rating], to: [track])
}
},
scrollToPlayingTrigger: scrollToPlayingTrigger scrollToPlayingTrigger: scrollToPlayingTrigger
) )
} }
@ -367,6 +385,23 @@ struct ContentView: View {
Text(error) Text(error)
} }
} }
.confirmationDialog(
deleteDialogTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Remove from Database", role: .destructive) {
try? library.deleteTracks(tracksToDelete, moveToTrash: false)
tracksToDelete = []
}
Button("Remove and Delete from Disk", role: .destructive) {
try? library.deleteTracks(tracksToDelete, moveToTrash: true)
tracksToDelete = []
}
Button("Cancel", role: .cancel) {
tracksToDelete = []
}
}
.sheet(isPresented: $showSmartPlaylistBuilder) { .sheet(isPresented: $showSmartPlaylistBuilder) {
SmartPlaylistBuilderSheet( SmartPlaylistBuilderSheet(
editingPlaylist: nil, editingPlaylist: nil,
@ -396,14 +431,13 @@ struct ContentView: View {
.sheet(item: $infoRequest) { req in .sheet(item: $infoRequest) { req in
TrackInfoSheet( TrackInfoSheet(
tracks: req.tracks, tracks: req.tracks,
onSave: { values, edited in allTracks: req.allTracks,
let targets = req.tracks onSave: { values, edited, targets in
infoRequest = nil
Task { Task {
_ = await library.applyTrackEdits(values, editing: edited, to: targets) _ = await library.applyTrackEdits(values, editing: edited, to: targets)
} }
}, },
onCancel: { infoRequest = nil } onDismiss: { infoRequest = nil }
) )
} }
} }
@ -416,6 +450,10 @@ struct ContentView: View {
return false return false
} }
private var deleteDialogTitle: String {
tracksToDelete.count == 1 ? "Delete Track" : "Delete \(tracksToDelete.count) Tracks"
}
private var trackContextMenuConfig: TrackContextMenuConfig { private var trackContextMenuConfig: TrackContextMenuConfig {
// Queue actions are local-only for v1: hidden when driving a remote device. // Queue actions are local-only for v1: hidden when driving a remote device.
let queueEnabled = !isDrivingRemoteDevice let queueEnabled = !isDrivingRemoteDevice
@ -440,7 +478,14 @@ struct ContentView: View {
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil, onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil,
onAddToNewPlaylist: { track in newPlaylistTrack = track }, onAddToNewPlaylist: { track in newPlaylistTrack = track },
onGetInfo: { tracks in onGetInfo: { tracks in
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) } let allTracks = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks, allTracks: allTracks) }
},
onDelete: { tracks in
if !tracks.isEmpty {
tracksToDelete = tracks
showDeleteConfirmation = true
}
} }
) )
} }

@ -40,7 +40,23 @@ struct MusicApp: App {
db: db, db: db,
showNewPlaylistAlert: $showNewPlaylistAlert, showNewPlaylistAlert: $showNewPlaylistAlert,
showSmartPlaylistBuilder: $showSmartPlaylistBuilder, showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
networkStatus: computeNetworkStatus() networkStatus: computeNetworkStatus(),
isHosting: hostServer?.isHosting ?? false,
onToggleHost: {
if hostServer?.isHosting ?? false {
hostServer?.stop()
} else {
startHosting()
}
},
isStreaming: streamingServer?.isRunning ?? false,
onToggleStream: {
if streamingServer?.isRunning ?? false {
stopStreamingServer()
} else {
startStreamingServer()
}
}
) )
} else if let error = initError { } else if let error = initError {
Text("Failed to initialize database: \(error)") Text("Failed to initialize database: \(error)")

@ -7,6 +7,10 @@ struct SearchBarView: View {
let onSaveSearch: (String) -> Void let onSaveSearch: (String) -> Void
let isShazamListening: Bool let isShazamListening: Bool
let onShazam: () -> Void let onShazam: () -> Void
let isHosting: Bool
let onToggleHost: () -> Void
let isStreaming: Bool
let onToggleStream: () -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@ -54,6 +58,22 @@ struct SearchBarView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(isShazamListening ? "Stop listening" : "Identify song with Shazam") .help(isShazamListening ? "Stop listening" : "Identify song with Shazam")
Button(action: onToggleHost) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 14))
.foregroundStyle(isHosting ? .green : .secondary)
}
.buttonStyle(.plain)
.help(isHosting ? "Stop hosting" : "Start host mode")
Button(action: onToggleStream) {
Image(systemName: "cloud")
.font(.system(size: 14))
.foregroundStyle(isStreaming ? .purple : .secondary)
}
.buttonStyle(.plain)
.help(isStreaming ? "Stop streaming server" : "Start streaming server")
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)

@ -4,37 +4,51 @@ import SwiftUI
// across tracks show a "Mixed" placeholder and only fields the user touches are // across tracks show a "Mixed" placeholder and only fields the user touches are
// applied. onSave hands back the edited values + the set of edited fields. // applied. onSave hands back the edited values + the set of edited fields.
struct TrackInfoSheet: View { struct TrackInfoSheet: View {
let tracks: [Track] let allTracks: [Track]
var onSave: (EditableTrackFields, Set<EditableTrackField>) -> Void var onSave: (EditableTrackFields, Set<EditableTrackField>, [Track]) -> Void
var onCancel: () -> Void var onDismiss: () -> Void
@State private var displayedTracks: [Track]
@State private var fields: EditableTrackFields @State private var fields: EditableTrackFields
@State private var mixed: Set<EditableTrackField> @State private var mixed: Set<EditableTrackField>
@State private var edited: Set<EditableTrackField> = [] @State private var edited: Set<EditableTrackField> = []
@State private var tab = 0 @State private var tab = 0
init(tracks: [Track], init(tracks: [Track],
onSave: @escaping (EditableTrackFields, Set<EditableTrackField>) -> Void, allTracks: [Track],
onCancel: @escaping () -> Void) { onSave: @escaping (EditableTrackFields, Set<EditableTrackField>, [Track]) -> Void,
self.tracks = tracks onDismiss: @escaping () -> Void) {
self.allTracks = allTracks
self.onSave = onSave self.onSave = onSave
self.onCancel = onCancel self.onDismiss = onDismiss
_displayedTracks = State(initialValue: tracks)
let (values, mixed) = EditableTrackFields.shared(across: tracks) let (values, mixed) = EditableTrackFields.shared(across: tracks)
_fields = State(initialValue: values) _fields = State(initialValue: values)
_mixed = State(initialValue: mixed) _mixed = State(initialValue: mixed)
} }
private var isMulti: Bool { tracks.count > 1 } private var isMulti: Bool { displayedTracks.count > 1 }
private var hasUnsupported: Bool { private var hasUnsupported: Bool {
tracks.contains { t in displayedTracks.contains { t in
["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased()) ["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased())
} }
} }
// Navigation within allTracks (single-track mode only)
private var canNavigate: Bool { !isMulti && allTracks.count > 1 }
private var currentIndex: Int? {
guard let track = displayedTracks.first else { return nil }
return allTracks.firstIndex(where: { $0.id == track.id })
}
private var hasPrevious: Bool { (currentIndex ?? 0) > 0 }
private var hasNext: Bool {
guard let idx = currentIndex else { return false }
return idx < allTracks.count - 1
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info") header
.font(.headline)
if hasUnsupported { if hasUnsupported {
Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.") Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.")
@ -53,15 +67,62 @@ struct TrackInfoSheet: View {
Divider() Divider()
HStack { HStack {
Spacer() Spacer()
Button("Cancel", action: onCancel) Button("Cancel", action: onDismiss)
Button("Save") { onSave(fields, edited) } Button("Save") {
.keyboardShortcut(.defaultAction) onSave(fields, edited, displayedTracks)
onDismiss()
}
.keyboardShortcut(.defaultAction)
} }
} }
.padding(20) .padding(20)
.frame(width: 460) .frame(width: 460)
} }
@ViewBuilder private var header: some View {
HStack {
Text(isMulti ? "Get Info — \(displayedTracks.count) tracks" : "Get Info")
.font(.headline)
Spacer()
if canNavigate {
HStack(spacing: 2) {
Button(action: navigatePrevious) {
Image(systemName: "chevron.left")
}
.disabled(!hasPrevious)
Button(action: navigateNext) {
Image(systemName: "chevron.right")
}
.disabled(!hasNext)
}
.buttonStyle(.borderless)
}
}
}
private func navigatePrevious() {
guard let idx = currentIndex, idx > 0 else { return }
navigateTo(allTracks[idx - 1])
}
private func navigateNext() {
guard let idx = currentIndex, idx < allTracks.count - 1 else { return }
navigateTo(allTracks[idx + 1])
}
private func navigateTo(_ track: Track) {
if !edited.isEmpty {
onSave(fields, edited, displayedTracks)
}
displayedTracks = [track]
let (values, mixedSet) = EditableTrackFields.shared(across: [track])
fields = values
mixed = mixedSet
edited = []
tab = 0
}
// Binding helper that marks a field edited whenever it changes. // Binding helper that marks a field edited whenever it changes.
private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> { private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> {
Binding( Binding(
@ -93,10 +154,16 @@ struct TrackInfoSheet: View {
labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) } labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) }
detailsNumericRow detailsNumericRow
labeled("Rating") { labeled("Rating") {
Stepper(value: Binding( HStack(spacing: 4) {
get: { fields.rating }, ForEach(1...5, id: \.self) { star in
set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) } Image(systemName: star <= fields.rating ? "star.fill" : "star")
), in: 0...5) { Text(String(repeating: "", count: fields.rating)) } .foregroundStyle(star <= fields.rating ? .yellow : .secondary)
.onTapGesture {
fields.rating = star == fields.rating ? 0 : star
edited.insert(.rating)
}
}
}
} }
detailsDateRow detailsDateRow
} }
@ -126,7 +193,7 @@ struct TrackInfoSheet: View {
} }
@ViewBuilder private var fileTab: some View { @ViewBuilder private var fileTab: some View {
if let t = tracks.first { if let t = displayedTracks.first {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
row("Kind", t.fileFormat.uppercased()) row("Kind", t.fileFormat.uppercased())
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "") row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "")

@ -46,6 +46,7 @@ struct TrackTableView: NSViewRepresentable {
let onDoubleClick: (Track) -> Void let onDoubleClick: (Track) -> Void
var contextMenuConfig: TrackContextMenuConfig? var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)? var onReorder: ((Int, Int) -> Void)?
var onRatingChange: ((Track, Int) -> Void)?
var scrollToPlayingTrigger: UUID = UUID() var scrollToPlayingTrigger: UUID = UUID()
func makeNSView(context: Context) -> NSScrollView { func makeNSView(context: Context) -> NSScrollView {
@ -197,6 +198,10 @@ struct TrackTableView: NSViewRepresentable {
let track = tracks[row] let track = tracks[row]
let colId = tableColumn?.identifier.rawValue ?? "" let colId = tableColumn?.identifier.rawValue ?? ""
if colId == "rating" {
return ratingCell(for: tableView, row: row, track: track)
}
if colId == "nowPlaying" { if colId == "nowPlaying" {
let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying") let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying")
let cellView: NSTableCellView let cellView: NSTableCellView
@ -286,8 +291,7 @@ struct TrackTableView: NSViewRepresentable {
case "lastPlayedAt": case "lastPlayedAt":
cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? "" cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? ""
case "rating": case "rating":
cell.stringValue = track.rating > 0 ? String(repeating: "", count: track.rating) : "" break
cell.alignment = .right
case "dateAdded": case "dateAdded":
cell.stringValue = Self.formatDate(track.dateAdded) cell.stringValue = Self.formatDate(track.dateAdded)
case "dateModified": case "dateModified":
@ -400,6 +404,23 @@ struct TrackTableView: NSViewRepresentable {
return true return true
} }
private func ratingCell(for tableView: NSTableView, row: Int, track: Track) -> NSView {
let cellId = NSUserInterfaceItemIdentifier("Cell_rating")
let cellView: RatingCellView
if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? RatingCellView {
cellView = existing
} else {
cellView = RatingCellView()
cellView.identifier = cellId
}
cellView.rating = track.rating
cellView.onRatingChange = { [weak self] newRating in
guard let self, row < self.tracks.count else { return }
self.parent.onRatingChange?(self.tracks[row], newRating)
}
return cellView
}
@objc func toggleColumn(_ sender: NSMenuItem) { @objc func toggleColumn(_ sender: NSMenuItem) {
guard let colId = sender.representedObject as? String, let tableView else { return } guard let colId = sender.representedObject as? String, let tableView else { return }
guard let column = tableView.tableColumns.first(where: { $0.identifier.rawValue == colId }) else { return } guard let column = tableView.tableColumns.first(where: { $0.identifier.rawValue == colId }) else { return }
@ -437,6 +458,74 @@ struct TrackTableView: NSViewRepresentable {
} }
} }
private final class RatingCellView: NSTableCellView {
var onRatingChange: ((Int) -> Void)?
private var starButtons: [NSButton] = []
var rating: Int = 0 {
didSet { updateStars() }
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
let stack = NSStackView()
stack.orientation = .horizontal
stack.spacing = 1
stack.translatesAutoresizingMaskIntoConstraints = false
for i in 1...5 {
let button = NSButton()
button.bezelStyle = .inline
button.isBordered = false
button.image = NSImage(systemSymbolName: "star", accessibilityDescription: "Star \(i)")
button.imagePosition = .imageOnly
button.imageScaling = .scaleProportionallyDown
button.tag = i
button.target = self
button.action = #selector(starClicked(_:))
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 14),
button.heightAnchor.constraint(equalToConstant: 14),
])
starButtons.append(button)
stack.addArrangedSubview(button)
}
addSubview(stack)
NSLayoutConstraint.activate([
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
private func updateStars() {
for button in starButtons {
let filled = button.tag <= rating
button.image = NSImage(
systemSymbolName: filled ? "star.fill" : "star",
accessibilityDescription: "Star \(button.tag)"
)
button.contentTintColor = filled ? .systemYellow : .tertiaryLabelColor
}
}
@objc private func starClicked(_ sender: NSButton) {
let newRating = sender.tag == rating ? 0 : sender.tag
rating = newRating
onRatingChange?(newRating)
}
}
private final class PlayableTableView: NSTableView { private final class PlayableTableView: NSTableView {
var enterAction: Selector? var enterAction: Selector?

Loading…
Cancel
Save