Files
Tonuino-Manager/Sources/TonuinoManager/UI/LibraryView.swift
T

1151 lines
58 KiB
Swift

import AppKit
import SwiftUI
struct LibraryView: View {
@EnvironmentObject private var app: AppModel
@AppStorage("library.folderSidebarWidth") private var folderSidebarWidth = 285.0
@StateObject private var player = AudioPlayerModel()
@State private var showImporter = false
@State private var showYouTubeImport = false
@State private var showCoverPicker = false
@State private var showMetadata = false
@State private var showTagging = false
@State private var showAcoustIDTagging = false
@State private var showCardWriter = false
@State private var showCleanConfirm = false
@State private var showCreateProfile = false
@State private var showSyncProfile = false
@State private var showProfileManager = false
@State private var profileToRename: LibraryProfile?
@State private var showDeleteProfileConfirm = false
@State private var editingTrack: MusicTrack?
@State private var editingFolderNumber: Int?
@State private var folderToRename: MusicFolder?
@State private var taggingTracks: [MusicTrack] = []
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Musikbibliothek").font(.largeTitle.bold())
Text(app.library.activeProfile.map { "Lokales Profil · \($0.name)" } ?? app.library.rootURL?.path ?? "Wähle eine SD-Karte oder erstelle ein lokales Profil")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Menu {
if !app.library.profiles.isEmpty {
Section("Lokale Profile") {
ForEach(app.library.profiles) { profile in
Button {
app.library.openProfile(profile.id)
app.status = "Lokales Profil \(profile.name) geöffnet"
} label: {
if app.library.activeProfileID == profile.id {
Label(profile.name, systemImage: "checkmark")
} else { Text(profile.name) }
}
}
}
Divider()
}
Button("Lokales Profil erstellen…", systemImage: "plus") { showCreateProfile = true }
Button("Profile verwalten…", systemImage: "slider.horizontal.3") { showProfileManager = true }
if let profile = app.library.activeProfile {
Button("Profil umbenennen…", systemImage: "pencil") { profileToRename = profile }
Button("Profil löschen…", systemImage: "trash", role: .destructive) { showDeleteProfileConfirm = true }
}
} label: {
Label(app.library.activeProfile?.name ?? "Profile", systemImage: "externaldrive.badge.person.crop")
}
.menuStyle(.borderedButton)
if app.library.activeProfile != nil {
Button("Mit SD-Karte synchronisieren…", systemImage: "arrow.triangle.2.circlepath") { showSyncProfile = true }
.disabled(app.library.isLoading || app.library.isSyncing)
}
Button("SD-Karte direkt öffnen…", systemImage: "externaldrive") { chooseCard() }
.help("Eine eingelegte SD-Karte direkt bearbeiten")
}.padding()
Divider()
if app.library.rootURL == nil {
mountedVolumesView
} else {
PersistentFolderSplitView(sidebarWidth: $folderSidebarWidth) {
folderSidebar.environmentObject(app)
} detail: {
trackPanel.environmentObject(app)
}
}
Divider()
PlayerBar(player: player, tracks: app.library.currentTracks).padding(.horizontal).padding(.vertical, 8)
}
.onChange(of: app.library.selectedFolder) { newValue in
if newValue != nil { app.library.selectedSystemFolderName = nil }
}
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.audio], allowsMultipleSelection: true) { result in
Task {
do { try await app.library.importFiles(try result.get(), to: app.library.selectedFolder ?? 0); app.status = "Tracks importiert" }
catch { app.report(error) }
}
}
.fileImporter(isPresented: $showCoverPicker, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in
do {
guard let url = try result.get().first else { return }
if let editingFolderNumber, let folder = app.library.folders.first(where: { $0.number == editingFolderNumber }) {
for track in folder.tracks { try CoverMetadata.write(url, to: track.url) }
} else { try CoverMetadata.write(url, to: editingTrack?.url) }
editingFolderNumber = nil
app.library.reload()
} catch { app.report(error) }
}
.sheet(isPresented: $showMetadata) {
if let track = editingTrack { MetadataEditor(track: track) { metadata in
do { try ID3Metadata.write(metadata, to: track.url); app.library.reload(); return true }
catch { app.report(error); return false }
}}
}
.sheet(isPresented: $showTagging) {
MusicBrainzTaggingView(tracks: taggingTracks) { app.library.reload() }
}
.sheet(isPresented: $showAcoustIDTagging) {
AcoustIDTaggingView(tracks: taggingTracks) { app.library.reload() }
}
.sheet(isPresented: $showCardWriter) {
if let folder = app.library.currentFolder {
FolderCardWriterView(folder: folder)
}
}
.sheet(isPresented: $showYouTubeImport) {
if let folder = app.library.currentFolder {
YouTubeImportView(folderNumber: folder.number) { }
}
}
.sheet(item: $folderToRename) { folder in
FolderNameEditor(folder: folder) { name in
do { try app.library.renameFolder(folder.number, to: name); return true }
catch { app.report(error); return false }
}
}
.sheet(isPresented: $showCreateProfile) { LocalProfileCreateView() }
.sheet(isPresented: $showProfileManager) { ProfileManagementView() }
.sheet(item: $profileToRename) { profile in
LocalProfileRenameView(profile: profile) { name in
do { try app.library.renameProfile(profile.id, to: name); return true }
catch { app.report(error); return false }
}
}
.sheet(isPresented: $showSyncProfile) {
if let profile = app.library.activeProfile { ProfileSyncView(profile: profile) }
}
.confirmationDialog("Lokales Profil löschen?", isPresented: $showDeleteProfileConfirm, titleVisibility: .visible) {
Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) {
guard let profile = app.library.activeProfile else { return }
do { try app.library.deleteProfile(profile.id); app.status = "Profil \(profile.name) gelöscht" }
catch { app.report(error) }
}
Button("Abbrechen", role: .cancel) { }
} message: {
Text("Das lokale Profil einschließlich aller Audiodateien wird in den macOS-Papierkorb verschoben.")
}
.confirmationDialog("SD-Karte bereinigen?", isPresented: $showCleanConfirm, titleVisibility: .visible) {
Button("Bereinigen", role: .destructive) { do { try app.library.cleanCard(); app.status = "SD-Karte bereinigt" } catch { app.report(error) } }
Button("Abbrechen", role: .cancel) { }
} message: {
let count = app.library.cleanPreview.map { $0.rootItems.count + $0.malformedFiles.count } ?? 0
Text("Es werden \(count) nicht zum TonUINO-Schema passende Einträge entfernt. Ordnernummern bleiben erhalten; Tracks werden neu nummeriert.")
}
}
private var mountedVolumesView: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Eingehängte Laufwerke").font(.headline)
Text("TonUINO-Treffer werden anhand der Ordnerstruktur geschätzt.")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if app.library.isDiscoveringVolumes { ProgressView().controlSize(.small) }
Button("Aktualisieren", systemImage: "arrow.clockwise") {
app.library.refreshMountedVolumes()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
if app.library.mountedVolumes.isEmpty {
EmptyState(
title: app.library.isDiscoveringVolumes ? "Laufwerke werden gesucht…" : "Keine Laufwerke gefunden",
icon: "externaldrive",
detail: app.library.isDiscoveringVolumes ? "" : "Schließe eine SD-Karte an oder aktualisiere die Laufwerksliste."
)
} else {
List(app.library.mountedVolumes) { volume in
HStack(alignment: .center, spacing: 12) {
Image(systemName: volume.match == .likely ? "sdcard.fill" : "externaldrive")
.font(.title3)
.foregroundStyle(matchColor(volume.match))
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(volume.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Text(volume.url.path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
Text(matchTitle(volume.match))
.font(.caption)
.foregroundStyle(matchColor(volume.match))
.lineLimit(1)
Text(matchDetail(volume.match))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
Button("Öffnen") {
app.library.open(volume.url)
app.status = "SD-Karte wird eingelesen…"
}
.buttonStyle(.bordered)
.fixedSize(horizontal: true, vertical: false)
}
.padding(.vertical, 5)
}
.listStyle(.inset)
}
}
}
private func matchTitle(_ match: TonuinoVolumeMatch) -> String {
switch match {
case .likely: "Wahrscheinliche TonUINO-Karte"
case .possible: "Möglicher Treffer"
case .notRecognized: "Kein TonUINO-Muster"
case .unreadable: "Nicht lesbar"
}
}
private func matchDetail(_ match: TonuinoVolumeMatch) -> String {
switch match {
case .likely: "mp3 und advert mit Audiodateien"
case .possible: "Teilweise passendes Ordnerschema"
case .notRecognized: "Laufwerk kann manuell geöffnet werden"
case .unreadable: "Zugriff auf den Inhalt nicht möglich"
}
}
private func matchColor(_ match: TonuinoVolumeMatch) -> Color {
switch match {
case .likely: .green
case .possible: .orange
case .notRecognized, .unreadable: .secondary
}
}
private var folderSidebar: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Ordner").font(.headline)
Spacer()
Button { do { try app.library.createFolder() } catch { app.report(error) } } label: { Image(systemName: "plus") }
.help("Ordner anlegen")
}.padding(.horizontal).padding(.top, 12)
List(selection: $app.library.selectedFolder) {
ForEach(app.library.folders) { folder in
let isEmpty = folder.tracks.isEmpty
HStack {
CoverThumbnail(url: folder.tracks.first?.url)
.onTapGesture { editingFolderNumber = folder.number; editingTrack = nil; showCoverPicker = true }
.help("Cover für alle Tracks dieses Ordners ändern")
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(folder.title)
.foregroundStyle(isEmpty ? Color.secondary : Color.primary)
.lineLimit(1)
if isEmpty {
Label("Leer", systemImage: "circle.slash")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
}
}
Text("Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks")
.font(.caption)
.foregroundStyle(.secondary.opacity(isEmpty ? 0.75 : 1))
}
}.tag(folder.number)
.contextMenu {
Button("Ordner benennen…", systemImage: "pencil") { folderToRename = folder }
Button("Ordner löschen", role: .destructive) { do { try app.library.deleteFolder(folder.number) } catch { app.report(error) } }
}
}
Section("Systemordner") {
ForEach(app.library.systemFolders) { folder in
Button {
app.library.selectedFolder = nil
app.library.selectedSystemFolderName = folder.name
} label: {
Label("\(folder.name) · \(folder.tracks.count) Tracks", systemImage: "folder")
.foregroundStyle(app.library.selectedSystemFolderName == folder.name ? Color.accentColor : Color.primary)
}.buttonStyle(.plain)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
Button("Karte bereinigen…", systemImage: "sparkles") {
do { _ = try app.library.cleanPreviewForCard(); showCleanConfirm = true } catch { app.report(error) }
}.disabled(app.library.isLoading).padding(.horizontal).padding(.bottom, 10)
}
}
private var trackPanel: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading) {
Text(app.library.currentFolder?.title ?? app.library.currentSystemFolder?.name ?? "Ordner auswählen").font(.title2.bold())
Text(app.library.currentFolder != nil ? "\(app.library.currentFolder?.tracks.count ?? 0) von maximal 255 Tracks" : "Systemordner · nur Anzeige und Wiedergabe")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if app.library.currentFolder != nil {
Menu("Auswahl", systemImage: "checkmark.circle") {
Button("Alle Tracks auswählen") {
app.library.selectedTrackIDs = Set(app.library.currentTracks.map(\.id))
}.keyboardShortcut("a", modifiers: [.command])
Button("Auswahl aufheben") {
app.library.selectedTrackIDs.removeAll()
}
}
}
if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil {
Button("Löschen", systemImage: "trash", role: .destructive) { do { try app.library.deleteTracks(app.library.selectedTrackIDs) } catch { app.report(error) } }
}
if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil {
Button("Nach oben", systemImage: "arrow.up") { do { try app.library.moveSelectedTracks(by: -1) } catch { app.report(error) } }
Button("Nach unten", systemImage: "arrow.down") { do { try app.library.moveSelectedTracks(by: 1) } catch { app.report(error) } }
}
if app.library.currentFolder != nil {
Button("MusicBrainz taggen…", systemImage: "tag") {
let folderTracks = app.library.currentTracks
taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) }
showTagging = true
}.disabled(app.library.currentTracks.isEmpty || app.library.isLoading)
.help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners taggen" : "Ausgewählte Tracks taggen")
Button("AcoustID identifizieren…", systemImage: "waveform") {
let folderTracks = app.library.currentTracks
taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) }
showAcoustIDTagging = true
}.disabled(app.library.currentTracks.isEmpty || app.library.isLoading)
.help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners identifizieren" : "Ausgewählte Tracks identifizieren")
Button("NFC-Karte schreiben…", systemImage: "wave.3.right") { showCardWriter = true }
.disabled(app.library.currentFolder == nil || app.library.isLoading)
.help("Ordner \(String(format: "%02d", app.library.currentFolder?.number ?? 0)) auf eine NFC-Karte schreiben")
}
Menu("Musik hinzufügen…", systemImage: "plus") {
Button("Audiodateien…", systemImage: "music.note") { showImporter = true }
Button("YouTube-Video…", systemImage: "play.rectangle") { showYouTubeImport = true }
}.disabled(app.library.selectedFolder == nil || app.library.isLoading)
}.padding()
Divider()
if app.library.isLoading {
VStack(spacing: 12) {
ProgressView()
Text(app.library.scanProgress.isEmpty ? "SD-Karte wird eingelesen…" : app.library.scanProgress)
.foregroundStyle(.secondary).monospacedDigit()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let scanError = app.library.scanError {
EmptyState(
title: app.library.activeProfile == nil ? "SD-Karte konnte nicht gelesen werden" : "Profil-Speicherort nicht verfügbar",
icon: "exclamationmark.triangle",
detail: scanError
)
} else {
let tracks = app.library.currentTracks
if !tracks.isEmpty {
Table(tracks, selection: $app.library.selectedTrackIDs) {
TableColumn("Nr.") { track in Text(track.url.deletingPathExtension().lastPathComponent).foregroundStyle(.secondary).frame(width: 38, alignment: .trailing) }.width(50)
TableColumn("Titel") { track in
HStack(spacing: 6) {
if player.currentURL?.standardizedFileURL == track.url.standardizedFileURL {
Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "pause.circle.fill")
.foregroundStyle(.tint)
.frame(width: 15)
.help(player.isPlaying ? "Wird gerade abgespielt" : "Aktueller Track, pausiert")
} else {
Color.clear.frame(width: 15, height: 12)
}
Text(track.displayTitle).lineLimit(1)
}
}
TableColumn("Interpret", value: \.artist)
TableColumn("Album", value: \.album)
TableColumn("") { track in
HStack(spacing: 8) {
Button { if let index = tracks.firstIndex(of: track) { player.play(tracks, startingAt: index) } } label: { Image(systemName: "play.fill") }.buttonStyle(.plain).help("Abspielen")
if app.library.currentFolder != nil {
Menu {
Button("Metadaten bearbeiten/anlegen…") { editingTrack = track; showMetadata = true }
Button("Cover einbetten…") { editingTrack = track; showCoverPicker = true }
if let index = tracks.firstIndex(of: track), index > 0 { Button("Nach oben") { do { try app.library.moveTrack(from: index, to: index - 1) } catch { app.report(error) } } }
if let index = tracks.firstIndex(of: track), index + 1 < tracks.count { Button("Nach unten") { do { try app.library.moveTrack(from: index, to: index + 1) } catch { app.report(error) } } }
Button("Track löschen", role: .destructive) { do { try app.library.deleteTracks([track.id]) } catch { app.report(error) } }
} label: { Image(systemName: "ellipsis") }.buttonStyle(.plain)
}
}
}.width(70)
}
} else {
EmptyState(title: "Noch keine Musik", icon: "music.note.list", detail: "Füge MP3s oder andere Audiodateien hinzu. Nicht-MP3-Dateien werden mit dem gebündelten FFmpeg konvertiert.")
}
}
}
}
private func chooseCard() {
let panel = NSOpenPanel(); panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false
panel.message = "Wähle das Volume der TonUINO-SD-Karte aus."
if panel.runModal() == .OK, let url = panel.url {
app.library.open(url)
app.status = "SD-Karte wird eingelesen…"
}
}
}
private struct LocalProfileCreateView: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@State private var name = "Neue TonUINO-Box"
@State private var sourceID = ""
@State private var storageBaseURL: URL?
@State private var isCreating = false
private var candidateVolumes: [TonuinoMountedVolume] {
app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Lokales Profil erstellen").font(.title2.bold())
Text("Das Profil speichert eine eigene Kopie der TonUINO-Karteninhalte auf diesem Mac.")
.foregroundStyle(.secondary)
Form {
TextField("Profilname", text: $name)
Picker("Startinhalt", selection: $sourceID) {
Text("Leere Bibliothek").tag("")
ForEach(candidateVolumes) { volume in
Text("Von \(volume.name) kopieren · \(volume.url.path)").tag(volume.id)
}
}
.pickerStyle(.menu)
VStack(alignment: .leading, spacing: 6) {
Text("Speicherort")
Text(storageBaseURL?.path ?? app.library.profileStorageURL.path)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(3)
HStack {
Button(storageBaseURL == nil ? "Anderen Ordner auswählen…" : "Ordner ändern…", systemImage: "folder") {
chooseStorageLocation()
}
if storageBaseURL != nil {
Button("Standardort", systemImage: "arrow.uturn.backward") { storageBaseURL = nil }
}
}
Text("Im ausgewählten Ordner wird „Tonuino Manager Profiles“ für dieses Profil angelegt.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if isCreating || app.library.isSyncing {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(app.library.syncProgress.isEmpty ? "Profil wird erstellt…" : app.library.syncProgress)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
HStack {
Spacer()
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
Button("Profil erstellen") { create() }
.buttonStyle(.borderedProminent)
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating || app.library.isSyncing)
}
}
.padding(24)
.frame(width: 540)
}
private func create() {
let source = candidateVolumes.first { $0.id == sourceID }?.url
isCreating = true
Task {
defer { isCreating = false }
do {
try await app.library.createProfile(name: name, copyingFrom: source, storageBaseURL: storageBaseURL)
app.status = "Lokales Profil \(name) erstellt"
dismiss()
} catch { app.report(error) }
}
}
private func chooseStorageLocation() {
let panel = NSOpenPanel()
panel.title = "Speicherort für dieses Profil auswählen"
panel.prompt = "Diesen Ordner verwenden"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK { storageBaseURL = panel.url }
}
}
private struct LocalProfileRenameView: View {
let profile: LibraryProfile
let save: (String) -> Bool
@Environment(\.dismiss) private var dismiss
@State private var name: String
init(profile: LibraryProfile, save: @escaping (String) -> Bool) {
self.profile = profile
self.save = save
_name = State(initialValue: profile.name)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Profil umbenennen").font(.title2.bold())
TextField("Profilname", text: $name).textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
Button("Sichern") { if save(name) { dismiss() } }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 420)
}
}
private enum ProfileManagerSheet: Identifiable {
case create
case rename(LibraryProfile)
case compare(LibraryProfile)
var id: String {
switch self {
case .create: "create"
case .rename(let profile): "rename-\(profile.id.uuidString)"
case .compare(let profile): "compare-\(profile.id.uuidString)"
}
}
}
private struct ProfileManagementView: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@State private var presentedSheet: ProfileManagerSheet?
@State private var profileToDelete: LibraryProfile?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Profile verwalten").font(.title2.bold())
Text("Prüfe Speicherorte und vergleiche lokale Profile mit einer eingelegten SD-Karte. Der Vergleich liest Dateiinformationen; er schreibt nichts auf die Karte.")
.font(.callout).foregroundStyle(.secondary)
if app.library.profiles.isEmpty {
EmptyState(title: "Noch keine Profile", icon: "externaldrive.badge.person.crop", detail: "Erstelle ein lokales Profil, um eine Bibliothek unabhängig von der SD-Karte zu verwalten.")
} else {
List(app.library.profiles) { profile in
profileRow(profile)
}
.listStyle(.inset)
}
HStack {
Button("Profil erstellen…", systemImage: "plus") { presentedSheet = .create }
Spacer()
Button("Fertig") { dismiss() }.keyboardShortcut(.cancelAction)
}
}
.padding(20)
.frame(width: 860, height: 560)
.sheet(item: $presentedSheet) { sheet in
switch sheet {
case .create:
LocalProfileCreateView()
case .rename(let profile):
LocalProfileRenameView(profile: profile) { name in
do { try app.library.renameProfile(profile.id, to: name); return true }
catch { app.report(error); return false }
}
case .compare(let profile):
ProfileSyncView(profile: profile)
}
}
.confirmationDialog(
"Lokales Profil löschen?",
isPresented: Binding(
get: { profileToDelete != nil },
set: { if !$0 { profileToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) {
guard let profile = profileToDelete else { return }
do {
try app.library.deleteProfile(profile.id)
app.status = "Profil \(profile.name) gelöscht"
} catch { app.report(error) }
profileToDelete = nil
}
Button("Abbrechen", role: .cancel) { profileToDelete = nil }
} message: {
Text("Das Profil wird aus der Übersicht entfernt. Seine lokalen Dateien werden in den macOS-Papierkorb verschoben.")
}
}
private func profileRow(_ profile: LibraryProfile) -> some View {
let isAvailable = FileManager.default.fileExists(atPath: profile.cardRootURL.path)
return HStack(alignment: .center, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(profile.name).font(.headline)
if app.library.activeProfileID == profile.id {
Text("Geöffnet").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2)
.background(.quaternary, in: Capsule())
}
Label(isAvailable ? "Daten verfügbar" : "Speicherort nicht verfügbar",
systemImage: isAvailable ? "checkmark.circle.fill" : "externaldrive.badge.exclamationmark")
.font(.caption)
.foregroundStyle(isAvailable ? Color.green : Color.orange)
}
Text(profile.cardRootURL.path)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
Spacer(minLength: 6)
Button("Öffnen", systemImage: "arrow.up.right.square") {
app.library.openProfile(profile.id)
app.status = "Lokales Profil \(profile.name) geöffnet"
dismiss()
}
Button("Vergleichen…", systemImage: "arrow.left.arrow.right") {
presentedSheet = .compare(profile)
}
Menu {
Button("Umbenennen…", systemImage: "pencil") { presentedSheet = .rename(profile) }
Button("Löschen…", systemImage: "trash", role: .destructive) { profileToDelete = profile }
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.help("Weitere Profilaktionen")
}
.padding(.vertical, 5)
}
}
private struct ProfileSyncView: View {
let profile: LibraryProfile
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@State private var selectedVolumeID = ""
@State private var preview: LibrarySyncPreview?
@State private var isPreviewing = false
@State private var showSyncConfirmation = false
private var candidateVolumes: [TonuinoMountedVolume] {
app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible }
}
private var selectedVolume: TonuinoMountedVolume? { candidateVolumes.first { $0.id == selectedVolumeID } }
private var changesToShow: [LibrarySyncChange] { Array((preview?.changes ?? []).prefix(500)) }
private var requiresExplicitRebind: Bool { preview?.requiresRebinding == true }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Profil vergleichen und reparieren").font(.title2.bold())
Text("Vergleiche „\(profile.name)“ mit einer SD-Karte. Du kannst fehlende lokale Dateien ergänzen oder das lokale Profil ausdrücklich auf die Karte anwenden.")
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Picker("Zielkarte", selection: $selectedVolumeID) {
Text("SD-Karte auswählen").tag("")
ForEach(candidateVolumes) { volume in
Text("\(volume.name) · \(volume.url.path)").tag(volume.id)
}
}
.frame(maxWidth: .infinity)
Button("Aktualisieren", systemImage: "arrow.clockwise") { app.library.refreshMountedVolumes() }
}
.disabled(isPreviewing || app.library.isSyncing)
if let preview {
GroupBox {
VStack(alignment: .leading, spacing: 8) {
Text("Ziel: \(preview.targetName) · \(preview.targetURL.path)")
.font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
HStack(spacing: 14) {
Label("\(preview.profileOnlyFileCount) nur lokal", systemImage: "internaldrive")
Label("\(preview.cardOnlyFileCount) nur auf SD", systemImage: "sdcard")
Label("\(preview.conflictingFileCount) abweichend", systemImage: "exclamationmark.circle")
}
.font(.caption)
Text("Vergleich anhand von Pfad, Dateigröße und Änderungszeit. Es wurden keine vollständigen Audiodateien gelesen und nichts auf die SD-Karte geschrieben.")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if requiresExplicitRebind {
Label("Diese SD-Karte ist bisher einem anderen Profil zugeordnet. Die Bestätigung ordnet „\(profile.name)“ dieser Karte neu zu.", systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange).fixedSize(horizontal: false, vertical: true)
} else if preview.requiresBinding {
Label(preview.targetUUID == nil
? "Die Karte stellt keine Volume-UUID bereit. Bitte prüfe den Zielpfad bei jedem Abgleich sorgfältig."
: "Mit der Bestätigung wird dieses Profil dauerhaft dieser SD-Karte zugeordnet.",
systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
if preview.changes.isEmpty {
Label("Keine Unterschiede bei Pfad, Dateigröße und Änderungszeit gefunden.", systemImage: "checkmark.circle")
.font(.caption).foregroundStyle(.green)
} else {
List(changesToShow) { change in
HStack(spacing: 8) {
Image(systemName: change.isDirectory ? "folder" : "doc")
.foregroundStyle(change.operation == .updated ? .orange : .secondary)
Text(changeLabel(change)).frame(width: 100, alignment: .leading)
.foregroundStyle(change.operation == .updated ? .orange : .secondary)
Text(change.path).lineLimit(1).truncationMode(.middle)
Spacer(minLength: 4)
if !change.isDirectory {
Text(ByteCountFormatter.string(fromByteCount: change.size, countStyle: .file))
.font(.caption2).foregroundStyle(.secondary)
}
}
.font(.caption)
.padding(.vertical, 2)
}
if preview.changes.count > changesToShow.count {
Text("Weitere \(preview.changes.count - changesToShow.count) Einträge sind in der Vorschau enthalten.")
.font(.caption2).foregroundStyle(.secondary)
}
}
} else if candidateVolumes.isEmpty {
EmptyState(title: "Keine SD-Karte gefunden", icon: "sdcard", detail: "Schließe eine Karte an und aktualisiere die Laufwerksliste.")
.frame(height: 150)
} else {
Text("Die Vorschau vergleicht Dateigröße und Änderungszeit. Sie liest keine kompletten Audiodateien ein und belastet große SD-Karten dadurch deutlich weniger.")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 120, alignment: .center)
}
if isPreviewing || app.library.isSyncing {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(app.library.syncProgress.isEmpty ? "Bitte warten…" : app.library.syncProgress)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
HStack {
Spacer()
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
if preview == nil {
Button("Vergleich starten") { makePreview() }
.buttonStyle(.borderedProminent)
.disabled(selectedVolume == nil || isPreviewing || app.library.isSyncing)
} else {
Button("Andere Karte wählen") { preview = nil }
.disabled(isPreviewing || app.library.isSyncing)
if (preview?.cardOnlyFileCount ?? 0) > 0 {
Button("Fehlende lokale Dateien ergänzen…", systemImage: "arrow.down.to.line") {
restoreMissingFiles()
}
.disabled(isPreviewing || app.library.isSyncing)
}
if preview?.changes.isEmpty == true && preview?.requiresBinding == false && preview?.requiresRebinding == false {
Button("Fertig") { dismiss() }
} else {
Button(requiresExplicitRebind ? "Profil auf SD neu zuordnen…" : "Lokales Profil auf SD anwenden…", systemImage: "arrow.up.to.line") {
showSyncConfirmation = true
}
.buttonStyle(.borderedProminent)
.disabled(app.library.isSyncing)
.confirmationDialog("Lokales Profil auf SD-Karte anwenden?", isPresented: $showSyncConfirmation, titleVisibility: .visible) {
Button(requiresExplicitRebind ? "Neu zuordnen und anwenden" : "Auf SD anwenden", role: .destructive) { synchronize() }
Button("Abbrechen", role: .cancel) { }
} message: {
let removalCount = preview?.removedCount ?? 0
Text("Die SD-Karte „\(preview?.targetName ?? "")“ wird an das lokale Profil angeglichen. \(removalCount) Einträge, die im Profil fehlen, werden auf der SD-Karte gelöscht.")
}
}
}
}
}
.padding(22)
.frame(width: 760, height: 650)
.onAppear {
if selectedVolumeID.isEmpty, candidateVolumes.count == 1 { selectedVolumeID = candidateVolumes[0].id }
}
.onChange(of: selectedVolumeID) { _ in preview = nil }
}
private func makePreview() {
guard let selectedVolume else { return }
isPreviewing = true
Task {
defer { isPreviewing = false }
do { preview = try await app.library.previewSync(profileID: profile.id, to: selectedVolume) }
catch { app.report(error) }
}
}
private func changeLabel(_ change: LibrarySyncChange) -> String {
switch change.operation {
case .added: "Nur lokal"
case .updated: "Abweichend"
case .removed: "Nur auf SD"
}
}
private func restoreMissingFiles() {
guard let preview else { return }
Task {
do {
let copiedCount = try await app.library.restoreMissingFiles(from: preview)
app.status = copiedCount == 0
? "Keine fehlenden lokalen Dateien mehr vorhanden"
: "\(copiedCount) Dateien in Profil \(profile.name) ergänzt"
self.preview = nil
} catch { app.report(error) }
}
}
private func synchronize() {
guard let preview else { return }
Task {
do {
try await app.library.synchronize(preview)
app.status = "Profil \(profile.name) mit \(preview.targetName) synchronisiert"
dismiss()
} catch { app.report(error) }
}
}
}
private struct PersistentFolderSplitView<Sidebar: View, Detail: View>: NSViewRepresentable {
@Binding var sidebarWidth: Double
let sidebar: Sidebar
let detail: Detail
init(
sidebarWidth: Binding<Double>,
@ViewBuilder sidebar: () -> Sidebar,
@ViewBuilder detail: () -> Detail
) {
_sidebarWidth = sidebarWidth
self.sidebar = sidebar()
self.detail = detail()
}
func makeCoordinator() -> Coordinator {
Coordinator(sidebarWidth: $sidebarWidth)
}
func makeNSView(context: Context) -> NSSplitView {
let splitView = NSSplitView()
splitView.isVertical = true
splitView.dividerStyle = .thin
splitView.delegate = context.coordinator
let sidebarHost = NSHostingView(rootView: sidebar)
sidebarHost.autoresizingMask = [.height]
let detailHost = NSHostingView(rootView: detail)
detailHost.autoresizingMask = [.width, .height]
splitView.addSubview(sidebarHost)
splitView.addSubview(detailHost)
context.coordinator.sidebarHost = sidebarHost
context.coordinator.detailHost = detailHost
return splitView
}
func updateNSView(_ splitView: NSSplitView, context: Context) {
let coordinator = context.coordinator
coordinator.sidebarWidth = $sidebarWidth
coordinator.sidebarHost?.rootView = sidebar
coordinator.detailHost?.rootView = detail
guard !coordinator.didApplyInitialWidth else { return }
coordinator.didApplyInitialWidth = true
DispatchQueue.main.async { [weak splitView] in
guard let splitView else { return }
let proposed = CGFloat(self.sidebarWidth)
splitView.setPosition(proposed, ofDividerAt: 0)
splitView.adjustSubviews()
}
}
static func dismantleNSView(_ splitView: NSSplitView, coordinator: Coordinator) {
coordinator.pendingSave?.cancel()
splitView.delegate = nil
}
final class Coordinator: NSObject, NSSplitViewDelegate {
var sidebarWidth: Binding<Double>
weak var sidebarHost: NSHostingView<Sidebar>?
weak var detailHost: NSHostingView<Detail>?
var didApplyInitialWidth = false
var pendingSave: DispatchWorkItem?
init(sidebarWidth: Binding<Double>) {
self.sidebarWidth = sidebarWidth
}
func splitView(
_ splitView: NSSplitView,
constrainSplitPosition proposedPosition: CGFloat,
ofSubviewAt dividerIndex: Int
) -> CGFloat {
let minimumSidebar: CGFloat = 220
let minimumDetail: CGFloat = 380
let maximumSidebar = max(minimumSidebar, splitView.bounds.width - minimumDetail - splitView.dividerThickness)
return min(max(proposedPosition, minimumSidebar), min(520, maximumSidebar))
}
func splitViewDidResizeSubviews(_ notification: Notification) {
guard let splitView = notification.object as? NSSplitView else { return }
pendingSave?.cancel()
let work = DispatchWorkItem { [weak self, weak splitView] in
guard let self, let splitView else { return }
if splitView.inLiveResize {
self.splitViewDidResizeSubviews(Notification(name: NSSplitView.didResizeSubviewsNotification, object: splitView))
return
}
guard let sidebar = splitView.subviews.first else { return }
let width = Double(sidebar.frame.width)
guard abs(width - self.sidebarWidth.wrappedValue) >= 1 else { return }
self.sidebarWidth.wrappedValue = width
}
pendingSave = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
}
}
}
private struct EmptyState: View {
let title: String
let icon: String
let detail: String
var body: some View {
VStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 38)).foregroundStyle(.secondary)
Text(title).font(.title3.bold())
Text(detail).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 420)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct CoverThumbnail: View {
var url: URL?
@State private var image: NSImage?
var body: some View {
Group {
if let image { Image(nsImage: image).resizable().scaledToFill() }
else { Image(systemName: "music.note").resizable().scaledToFit().padding(8).foregroundStyle(.secondary) }
}.frame(width: 36, height: 36).background(.quaternary).clipShape(RoundedRectangle(cornerRadius: 5))
.task(id: url) {
guard let url else { image = nil; return }
image = await CoverImageCache.shared.image(for: url)?.image
}
}
}
private actor CoverImageCache {
static let shared = CoverImageCache()
private var images: [URL: CachedCoverImage] = [:]
func image(for url: URL) async -> CachedCoverImage? {
if let image = images[url] { return image }
let decoded = await Task.detached(priority: .utility) { CoverMetadata.read(from: url).map(CachedCoverImage.init) }.value
if let decoded { images[url] = decoded }
return decoded
}
}
private final class CachedCoverImage: @unchecked Sendable {
let image: NSImage
init(_ image: NSImage) { self.image = image }
}
private struct PlayerBar: View {
@ObservedObject var player: AudioPlayerModel
let tracks: [MusicTrack]
var body: some View {
HStack(spacing: 12) {
CoverThumbnail(url: player.currentURL)
VStack(alignment: .leading, spacing: 3) {
Text(player.title.isEmpty ? "Kein Track ausgewählt" : player.title).lineLimit(1)
let showsStatus = player.isLoading || player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen")
Text(showsStatus || player.artist.isEmpty ? player.statusMessage : player.artist)
.font(.caption).foregroundStyle(player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") ? .red : .secondary).lineLimit(1)
}.frame(width: 210, alignment: .leading)
Button { player.previous() } label: { Image(systemName: "backward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading)
Button { toggleOrPlayFolder() } label: {
if player.isLoading { ProgressView().controlSize(.small) }
else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") }
}
.buttonStyle(.plain)
.font(.title3)
.keyboardShortcut(.space, modifiers: [])
.help(player.isCurrentTrack(in: tracks) ? "Wiedergabe/Pause (Leertaste)" : "Ordner abspielen (Leertaste)")
.disabled(player.isLoading || tracks.isEmpty)
Button { player.next() } label: { Image(systemName: "forward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading)
Text(time(player.position)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Slider(value: Binding(get: { player.position }, set: { player.seek($0) }), in: 0...max(player.duration, 1)).disabled(player.duration == 0)
Text(time(player.duration)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Image(systemName: "speaker.wave.2").foregroundStyle(.secondary)
Slider(value: $player.volume, in: 0...1).frame(width: 90)
}.buttonStyle(.plain)
}
private func toggleOrPlayFolder() {
if player.isCurrentTrack(in: tracks) {
player.toggle()
} else {
player.play(tracks, startingAt: 0)
}
}
private func time(_ seconds: Double) -> String { guard seconds.isFinite else { return "0:00" }; return String(format: "%d:%02d", Int(seconds) / 60, Int(seconds) % 60) }
}
private struct MetadataEditor: View {
let track: MusicTrack
let save: (TrackMetadata) -> Bool
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var artist = ""
@State private var album = ""
@State private var albumArtist = ""
@State private var trackNumber = ""
@State private var discNumber = ""
@State private var year = ""
@State private var genre = ""
@State private var acoustID = ""
@State private var musicBrainzRecordingID = ""
@State private var musicBrainzReleaseID = ""
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Track-Metadaten bearbeiten oder anlegen").font(.title2.bold())
Text("Speichern legt bei Bedarf einen neuen ID3-Tag in der MP3-Datei an.").font(.caption).foregroundStyle(.secondary)
LabeledContent("Titel") { TextField("Titel", text: $title) }
LabeledContent("Interpret") { TextField("Interpret", text: $artist) }
LabeledContent("Album") { TextField("Album", text: $album) }
LabeledContent("Albuminterpret") { TextField("Albuminterpret", text: $albumArtist) }
HStack { LabeledContent("Track") { TextField("1", text: $trackNumber) }; LabeledContent("CD") { TextField("1", text: $discNumber) } }
HStack { LabeledContent("Jahr") { TextField("Jahr", text: $year) }; LabeledContent("Genre") { TextField("Genre", text: $genre) } }
LabeledContent("AcoustID-ID") { TextField("Optional", text: $acoustID) }
LabeledContent("MusicBrainz Recording-ID") { TextField("Optional", text: $musicBrainzRecordingID) }
LabeledContent("MusicBrainz Release-ID") { TextField("Optional", text: $musicBrainzReleaseID) }
HStack { Spacer(); Button("Abbrechen") { dismiss() }; Button("Sichern") {
if save(TrackMetadata(title: title, artist: artist, album: album, albumArtist: albumArtist, trackNumber: trackNumber, discNumber: discNumber, year: year, genre: genre, acoustID: acoustID, musicBrainzRecordingID: musicBrainzRecordingID, musicBrainzReleaseID: musicBrainzReleaseID)) { dismiss() }
}.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width: 560).onAppear {
let tags = ID3Metadata.read(track.url)
title = tags.title; artist = tags.artist; album = tags.album; albumArtist = tags.albumArtist
trackNumber = tags.trackNumber; discNumber = tags.discNumber; year = tags.year; genre = tags.genre
acoustID = tags.acoustID
musicBrainzRecordingID = tags.musicBrainzRecordingID; musicBrainzReleaseID = tags.musicBrainzReleaseID
}
}
}
private struct FolderNameEditor: View {
let folder: MusicFolder
let save: (String) -> Bool
@Environment(\.dismiss) private var dismiss
@State private var name: String
init(folder: MusicFolder, save: @escaping (String) -> Bool) {
self.folder = folder
self.save = save
_name = State(initialValue: folder.customName ?? folder.title)
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Ordner \(String(format: "%02d", folder.number)) benennen").font(.title2.bold())
TextField("Anzeigename", text: $name)
.textFieldStyle(.roundedBorder)
.onSubmit { if save(name) { dismiss() } }
Text("Der Name wird auf der SD-Karte gespeichert und bleibt beim nächsten Öffnen erhalten. Leeres Feld entfernt den eigenen Namen.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Spacer()
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
Button("Sichern") { if save(name) { dismiss() } }.keyboardShortcut(.defaultAction)
}
}
.padding(24)
.frame(width: 460)
}
}
enum CoverMetadata {
static func read(from url: URL) -> NSImage? {
guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), data.count > 10,
String(data: data.prefix(3), encoding: .ascii) == "ID3" else { return nil }
let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) })
var i = 10
while i + 10 < tagEnd {
guard let id = String(data: data[i..<i+4], encoding: .ascii), id.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }) else { return nil }
let size = Int(data[i+4]) << 24 | Int(data[i+5]) << 16 | Int(data[i+6]) << 8 | Int(data[i+7]); i += 10
guard size > 0, i + size <= tagEnd else { return nil }
if id == "APIC" {
let frame = data[i..<i+size]; guard frame.count > 5 else { return nil }
let rest = Data(frame.dropFirst())
if let terminator = rest.firstIndex(of: 0), terminator + 2 < rest.endIndex {
let imageData = Data(rest[terminator + 2..<rest.endIndex]); return NSImage(data: imageData)
}
}
i += size
}
return nil
}
static func write(_ imageURL: URL, to audioURL: URL?) throws {
guard let audioURL, var data = try? Data(contentsOf: audioURL), data.count >= 10,
String(data: data.prefix(3), encoding: .ascii) == "ID3" else { throw AudioImportError.conversionFailed }
let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) })
var frames = Data(); var i = 10
while i + 10 <= tagEnd {
guard let id = String(data: data[i..<i+4], encoding: .ascii) else { break }
let size = Int(data[i+4]) << 24 | Int(data[i+5]) << 16 | Int(data[i+6]) << 8 | Int(data[i+7]); guard size > 0, i + 10 + size <= tagEnd else { break }
if id != "APIC" { frames.append(data[i..<i+10+size]) }; i += 10 + size
}
let image = try Data(contentsOf: imageURL)
let mime = imageURL.pathExtension.lowercased() == "png" ? "image/png" : "image/jpeg"
let body = Data([3]) + Data(mime.utf8) + Data([0, 3, 0]) + image
var frame = Data("APIC".utf8); let n = body.count; frame.append(contentsOf: [UInt8((n >> 24)&255), UInt8((n >> 16)&255), UInt8((n >> 8)&255), UInt8(n&255), 0, 0]); frame.append(body); frames.append(frame)
let prefix = data.prefix(10); let oldAudioStart = tagEnd
let newTag = Data("ID3".utf8) + Data([3, 0, 0, UInt8((frames.count >> 21)&127), UInt8((frames.count >> 14)&127), UInt8((frames.count >> 7)&127), UInt8(frames.count&127)]) + frames
data = newTag + data[oldAudioStart...]
_ = prefix
try data.write(to: audioURL, options: .atomic)
}
}