diff --git a/Music/Views/TrackInfoSheet.swift b/Music/Views/TrackInfoSheet.swift new file mode 100644 index 0000000..f885204 --- /dev/null +++ b/Music/Views/TrackInfoSheet.swift @@ -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) -> Void + var onCancel: () -> Void + + @State private var fields: EditableTrackFields + @State private var mixed: Set + @State private var edited: Set = [] + @State private var tab = 0 + + init(tracks: [Track], + onSave: @escaping (EditableTrackFields, Set) -> 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) -> Binding { + 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) -> Binding { + 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(_ 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) + } + } +}