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: NSViewRepresentable { @Binding var sidebarWidth: Double let sidebar: Sidebar let detail: Detail init( sidebarWidth: Binding, @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 weak var sidebarHost: NSHostingView? weak var detailHost: NSHostingView? var didApplyInitialWidth = false var pendingSave: DispatchWorkItem? init(sidebarWidth: Binding) { 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..= 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.. 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..= 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.. 0, i + 10 + size <= tagEnd else { break } if id != "APIC" { frames.append(data[i..> 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) } }