|
|
|
|
@ -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,8 +67,11 @@ struct TrackInfoSheet: View { |
|
|
|
|
Divider() |
|
|
|
|
HStack { |
|
|
|
|
Spacer() |
|
|
|
|
Button("Cancel", action: onCancel) |
|
|
|
|
Button("Save") { onSave(fields, edited) } |
|
|
|
|
Button("Cancel", action: onDismiss) |
|
|
|
|
Button("Save") { |
|
|
|
|
onSave(fields, edited, displayedTracks) |
|
|
|
|
onDismiss() |
|
|
|
|
} |
|
|
|
|
.keyboardShortcut(.defaultAction) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -62,6 +79,50 @@ struct TrackInfoSheet: View { |
|
|
|
|
.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" } ?? "—") |
|
|
|
|
|