parent
515f257f83
commit
f0a5677f68
@ -0,0 +1,141 @@ |
||||
import SwiftUI |
||||
|
||||
// Get Info dialog. Edits one or many tracks. For multi-edit, fields that differ |
||||
// 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 |
||||
|
||||
@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 |
||||
self.onSave = onSave |
||||
self.onCancel = onCancel |
||||
let (values, mixed) = EditableTrackFields.shared(across: tracks) |
||||
_fields = State(initialValue: values) |
||||
_mixed = State(initialValue: mixed) |
||||
} |
||||
|
||||
private var isMulti: Bool { tracks.count > 1 } |
||||
private var hasUnsupported: Bool { |
||||
tracks.contains { t in |
||||
["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased()) |
||||
} |
||||
} |
||||
|
||||
var body: some View { |
||||
VStack(alignment: .leading, spacing: 16) { |
||||
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info") |
||||
.font(.headline) |
||||
|
||||
if hasUnsupported { |
||||
Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.") |
||||
.font(.caption).foregroundStyle(.secondary) |
||||
} |
||||
|
||||
Picker("", selection: $tab) { |
||||
Text("Details").tag(0) |
||||
if !isMulti { Text("File").tag(1) } |
||||
} |
||||
.pickerStyle(.segmented) |
||||
.labelsHidden() |
||||
|
||||
if tab == 0 { detailsTab } else { fileTab } |
||||
|
||||
Divider() |
||||
HStack { |
||||
Spacer() |
||||
Button("Cancel", action: onCancel) |
||||
Button("Save") { onSave(fields, edited) } |
||||
.keyboardShortcut(.defaultAction) |
||||
} |
||||
} |
||||
.padding(20) |
||||
.frame(width: 460) |
||||
} |
||||
|
||||
// Binding helper that marks a field edited whenever it changes. |
||||
private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> { |
||||
Binding( |
||||
get: { mixed.contains(field) && !edited.contains(field) ? "" : fields[keyPath: keyPath] }, |
||||
set: { fields[keyPath: keyPath] = $0; edited.insert(field) } |
||||
) |
||||
} |
||||
private func int(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, Int?>) -> Binding<String> { |
||||
Binding( |
||||
get: { mixed.contains(field) && !edited.contains(field) ? "" : (fields[keyPath: keyPath].map(String.init) ?? "") }, |
||||
set: { fields[keyPath: keyPath] = Int($0.filter(\.isNumber)); edited.insert(field) } |
||||
) |
||||
} |
||||
private func placeholder(_ field: EditableTrackField) -> String { |
||||
mixed.contains(field) && !edited.contains(field) ? "Mixed" : "" |
||||
} |
||||
|
||||
private var detailsTab: some View { |
||||
detailsTextFields |
||||
} |
||||
|
||||
@ViewBuilder private var detailsTextFields: some View { |
||||
VStack(alignment: .leading, spacing: 8) { |
||||
labeled("Title") { TextField(placeholder(.title), text: text(.title, \.title)) } |
||||
labeled("Artist") { TextField(placeholder(.artist), text: text(.artist, \.artist)) } |
||||
labeled("Album Artist") { TextField(placeholder(.albumArtist), text: text(.albumArtist, \.albumArtist)) } |
||||
labeled("Album") { TextField(placeholder(.album), text: text(.album, \.album)) } |
||||
labeled("Genre") { TextField(placeholder(.genre), text: text(.genre, \.genre)) } |
||||
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)) } |
||||
} |
||||
} |
||||
.textFieldStyle(.roundedBorder) |
||||
} |
||||
|
||||
@ViewBuilder private var detailsNumericRow: some View { |
||||
HStack(spacing: 12) { |
||||
labeled("Year") { TextField(placeholder(.year), text: int(.year, \.year)).frame(width: 70) } |
||||
labeled("Track") { TextField(placeholder(.trackNumber), text: int(.trackNumber, \.trackNumber)).frame(width: 50) } |
||||
labeled("Disc") { TextField(placeholder(.discNumber), text: int(.discNumber, \.discNumber)).frame(width: 50) } |
||||
labeled("BPM") { TextField(placeholder(.bpm), text: int(.bpm, \.bpm)).frame(width: 60) } |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder private var fileTab: some View { |
||||
if let t = tracks.first { |
||||
VStack(alignment: .leading, spacing: 6) { |
||||
row("Kind", t.fileFormat.uppercased()) |
||||
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "—") |
||||
row("Sample Rate", t.sampleRate.map { "\($0) Hz" } ?? "—") |
||||
row("Size", ByteCountFormatter.string(fromByteCount: t.fileSize, countStyle: .file)) |
||||
row("Duration", String(format: "%d:%02d", Int(t.duration) / 60, Int(t.duration) % 60)) |
||||
row("Plays", "\(t.playCount)") |
||||
row("Where", URL(string: t.fileURL)?.path ?? t.fileURL) |
||||
} |
||||
.font(.system(size: 12)) |
||||
} |
||||
} |
||||
|
||||
private func labeled<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View { |
||||
VStack(alignment: .leading, spacing: 2) { |
||||
Text(title).font(.caption).foregroundStyle(.secondary) |
||||
content() |
||||
} |
||||
} |
||||
private func row(_ k: String, _ v: String) -> some View { |
||||
HStack(alignment: .top) { |
||||
Text(k).foregroundStyle(.secondary).frame(width: 90, alignment: .leading) |
||||
Text(v).textSelection(.enabled) |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue