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