feat: add tabbed TrackInfoSheet

feat/music-streaming
Laurent 1 month ago
parent 515f257f83
commit f0a5677f68
  1. 141
      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<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…
Cancel
Save