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_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
@ -494,7 +494,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 29;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;

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

@ -7,6 +7,10 @@ struct SearchBarView: View {
let onSaveSearch: (String) -> Void
let isShazamListening: Bool
let onShazam: () -> Void
let isHosting: Bool
let onToggleHost: () -> Void
let isStreaming: Bool
let onToggleStream: () -> Void
var body: some View {
HStack(spacing: 12) {
@ -54,6 +58,22 @@ struct SearchBarView: View {
}
.buttonStyle(.plain)
.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(.vertical, 8)

@ -4,37 +4,51 @@ import SwiftUI
// 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.
struct TrackInfoSheet: View {
let tracks: [Track]
var onSave: (EditableTrackFields, Set<EditableTrackField>) -> Void
var onCancel: () -> Void
let allTracks: [Track]
var onSave: (EditableTrackFields, Set<EditableTrackField>, [Track]) -> Void
var onDismiss: () -> Void
@State private var displayedTracks: [Track]
@State private var fields: EditableTrackFields
@State private var mixed: Set<EditableTrackField>
@State private var edited: Set<EditableTrackField> = []
@State private var tab = 0
init(tracks: [Track],
onSave: @escaping (EditableTrackFields, Set<EditableTrackField>) -> Void,
onCancel: @escaping () -> Void) {
self.tracks = tracks
allTracks: [Track],
onSave: @escaping (EditableTrackFields, Set<EditableTrackField>, [Track]) -> Void,
onDismiss: @escaping () -> Void) {
self.allTracks = allTracks
self.onSave = onSave
self.onCancel = onCancel
self.onDismiss = onDismiss
_displayedTracks = State(initialValue: tracks)
let (values, mixed) = EditableTrackFields.shared(across: tracks)
_fields = State(initialValue: values)
_mixed = State(initialValue: mixed)
}
private var isMulti: Bool { tracks.count > 1 }
private var isMulti: Bool { displayedTracks.count > 1 }
private var hasUnsupported: Bool {
tracks.contains { t in
displayedTracks.contains { t in
["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 {
VStack(alignment: .leading, spacing: 16) {
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info")
.font(.headline)
header
if hasUnsupported {
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()
HStack {
Spacer()
Button("Cancel", action: onCancel)
Button("Save") { onSave(fields, edited) }
.keyboardShortcut(.defaultAction)
Button("Cancel", action: onDismiss)
Button("Save") {
onSave(fields, edited, displayedTracks)
onDismiss()
}
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.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.
private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> {
Binding(
@ -93,10 +154,16 @@ struct TrackInfoSheet: View {
labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) }
detailsNumericRow
labeled("Rating") {
Stepper(value: Binding(
get: { fields.rating },
set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) }
), in: 0...5) { Text(String(repeating: "", count: fields.rating)) }
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= fields.rating ? "star.fill" : "star")
.foregroundStyle(star <= fields.rating ? .yellow : .secondary)
.onTapGesture {
fields.rating = star == fields.rating ? 0 : star
edited.insert(.rating)
}
}
}
}
detailsDateRow
}
@ -126,7 +193,7 @@ struct TrackInfoSheet: View {
}
@ViewBuilder private var fileTab: some View {
if let t = tracks.first {
if let t = displayedTracks.first {
VStack(alignment: .leading, spacing: 6) {
row("Kind", t.fileFormat.uppercased())
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "")

@ -46,6 +46,7 @@ struct TrackTableView: NSViewRepresentable {
let onDoubleClick: (Track) -> Void
var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)?
var onRatingChange: ((Track, Int) -> Void)?
var scrollToPlayingTrigger: UUID = UUID()
func makeNSView(context: Context) -> NSScrollView {
@ -197,6 +198,10 @@ struct TrackTableView: NSViewRepresentable {
let track = tracks[row]
let colId = tableColumn?.identifier.rawValue ?? ""
if colId == "rating" {
return ratingCell(for: tableView, row: row, track: track)
}
if colId == "nowPlaying" {
let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying")
let cellView: NSTableCellView
@ -286,8 +291,7 @@ struct TrackTableView: NSViewRepresentable {
case "lastPlayedAt":
cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? ""
case "rating":
cell.stringValue = track.rating > 0 ? String(repeating: "", count: track.rating) : ""
cell.alignment = .right
break
case "dateAdded":
cell.stringValue = Self.formatDate(track.dateAdded)
case "dateModified":
@ -400,6 +404,23 @@ struct TrackTableView: NSViewRepresentable {
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) {
guard let colId = sender.representedObject as? String, let tableView 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 {
var enterAction: Selector?

Loading…
Cancel
Save