import AppKit import SwiftUI struct ContentView: View { var body: some View { TabView { ConversionToolView() .tabItem { Label("Datenumwandlung", systemImage: "arrow.triangle.2.circlepath") } SDCardToolView() .tabItem { Label("SD-Karte", systemImage: "sdcard") } MP3GainToolView() .tabItem { Label("MP3Gain", systemImage: "waveform") } ID3TagToolView() .tabItem { Label("ID3-Tags", systemImage: "tag") } } } } private struct ConversionToolView: View { @State private var inputURL: URL? @State private var outputURL: URL? @State private var episodes: [EpisodePlan] = [] @State private var messages: [ExportMessage] = [] @State private var overwriteExisting = false @State private var writeReport = true @State private var cleanAfterExport = true @State private var validateAfterExport = true @State private var trackMode: TonUinoExportOptions.TrackMode = .merged @State private var isExporting = false private var canExport: Bool { inputURL != nil && outputURL != nil && !episodes.isEmpty && !isExporting } var body: some View { VStack(spacing: 0) { toolbar Divider() HSplitView { VStack(alignment: .leading, spacing: 12) { header episodeTable } .frame(minWidth: 520) VStack(alignment: .leading, spacing: 12) { Text("Protokoll") .font(.headline) messageList } .frame(minWidth: 280) .padding(16) } } .onChange(of: inputURL) { _ in reloadPreview() } .onAppear(perform: reloadPreview) } private var toolbar: some View { HStack(spacing: 10) { Button { chooseFolder(title: "Input-Ordner wählen") { url in inputURL = url } } label: { Label("Input", systemImage: "folder") } Button { chooseFolder(title: "Output-Ordner wählen") { url in outputURL = url } } label: { Label("Output", systemImage: "externaldrive") } Button { reloadPreview() } label: { Label("Aktualisieren", systemImage: "arrow.clockwise") } .disabled(inputURL == nil) Spacer() Picker("Modus", selection: $trackMode) { Text("Zusammenführen").tag(TonUinoExportOptions.TrackMode.merged) Text("Getrennt").tag(TonUinoExportOptions.TrackMode.separate) } .pickerStyle(.segmented) .frame(width: 230) HStack(spacing: 12) { Toggle("Überschreiben", isOn: $overwriteExisting) Toggle("Bericht", isOn: $writeReport) Toggle("dot_clean", isOn: $cleanAfterExport) Toggle("Prüfen", isOn: $validateAfterExport) } .toggleStyle(.checkbox) Button { export() } label: { Label("Exportieren", systemImage: "square.and.arrow.down") } .buttonStyle(.borderedProminent) .disabled(!canExport) } .padding(12) } private var header: some View { VStack(alignment: .leading, spacing: 8) { Text("TonUINO Export") .font(.title2.weight(.semibold)) VStack(alignment: .leading, spacing: 4) { PathRow(label: "Input", url: inputURL) PathRow(label: "Output", url: outputURL) } Text("\(episodes.count) Folgen, \(episodes.reduce(0) { $0 + $1.tracks.count }) Quelldateien") .foregroundStyle(.secondary) } .padding([.horizontal, .top], 16) } private var episodeTable: some View { Table(episodes) { TableColumn("Ziel") { episode in Text(episode.targetFolderName) .monospacedDigit() } .width(48) TableColumn("Folge") { episode in VStack(alignment: .leading, spacing: 2) { Text(episode.sourceName) .lineLimit(1) if !episode.warnings.isEmpty { Text(episode.warnings.joined(separator: " · ")) .font(.caption) .foregroundStyle(.orange) .lineLimit(2) } } } TableColumn("Quellen") { episode in Text("\(episode.tracks.count)") .monospacedDigit() } .width(64) TableColumn("Zieldatei") { episode in Text(targetFileDescription(for: episode)) .monospacedDigit() } .width(108) TableColumn("Erste Quelle") { episode in Text(episode.tracks.first?.source.lastPathComponent ?? "-") .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.horizontal, 16) .padding(.bottom, 16) } private var messageList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { if messages.isEmpty { Text("Noch kein Export ausgeführt.") .foregroundStyle(.secondary) } else { ForEach(messages) { message in HStack(alignment: .top, spacing: 8) { Image(systemName: message.iconName) .foregroundStyle(message.color) .frame(width: 18) Text(message.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } } } } } private func reloadPreview() { guard let inputURL else { episodes = [] return } do { let scanner = TonUinoScanner(fileManager: .default) episodes = try scanner.scan(inputURL: inputURL) messages = scannerMessages(for: episodes) } catch { episodes = [] messages = [.error("Vorschau fehlgeschlagen: \(error.localizedDescription)")] } } private func scannerMessages(for episodes: [EpisodePlan]) -> [ExportMessage] { var result: [ExportMessage] = [] if episodes.isEmpty { result.append(.warning("Keine Unterordner mit Audiodateien gefunden.")) } for episode in episodes where !episode.warnings.isEmpty { result.append(.warning("\(episode.sourceName): \(episode.warnings.joined(separator: ", "))")) } return result } private func export() { guard let outputURL else { return } isExporting = true messages = [.info("Export gestartet.")] DispatchQueue.global(qos: .userInitiated).async { let exporter = TonUinoExporter(fileManager: .default) var result = exporter.export( episodes: episodes, outputURL: outputURL, options: TonUinoExportOptions( overwriteExisting: overwriteExisting, writeReport: writeReport, trackMode: trackMode ) ) if cleanAfterExport { result.append(contentsOf: SDCardCleaner().clean(url: outputURL)) } if validateAfterExport { result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: outputURL)) } DispatchQueue.main.async { messages = result isExporting = false } } } private func targetFileDescription(for episode: EpisodePlan) -> String { switch trackMode { case .merged: "001.mp3" case .separate: episode.tracks.count <= 1 ? "001.mp3" : "001...\(String(format: "%03d", episode.tracks.count)).mp3" } } } private struct SDCardToolView: View { @State private var sdCardURL: URL? @State private var messages: [ExportMessage] = [] @State private var isWorking = false private var canRun: Bool { sdCardURL != nil && !isWorking } var body: some View { VStack(spacing: 0) { HStack(spacing: 10) { Button { chooseFolder(title: "TonUINO SD-Karte wählen") { url in sdCardURL = url messages = [] } } label: { Label("SD-Karte", systemImage: "sdcard") } Spacer() Button { validateSDCard() } label: { Label("Prüfen", systemImage: "checklist") } .disabled(!canRun) Button { cleanSDCard() } label: { Label("dot_clean ausführen", systemImage: "sparkles") } .buttonStyle(.borderedProminent) .disabled(!canRun) } .padding(12) Divider() VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("SD-Karte bereinigen") .font(.title2.weight(.semibold)) PathRow(label: "Karte", url: sdCardURL) } VStack(alignment: .leading, spacing: 10) { Text("Protokoll") .font(.headline) ScrollView { LazyVStack(alignment: .leading, spacing: 8) { if messages.isEmpty { Text("Noch keine SD-Karte geprüft oder bereinigt.") .foregroundStyle(.secondary) } else { ForEach(messages) { message in HStack(alignment: .top, spacing: 8) { Image(systemName: message.iconName) .foregroundStyle(message.color) .frame(width: 18) Text(message.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } } } .frame(maxWidth: .infinity, alignment: .leading) } } Spacer(minLength: 0) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } private func cleanSDCard() { guard let sdCardURL else { return } isWorking = true messages = [.info("dot_clean gestartet.")] DispatchQueue.global(qos: .userInitiated).async { var result = SDCardCleaner().clean(url: sdCardURL) result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL)) DispatchQueue.main.async { messages = result isWorking = false } } } private func validateSDCard() { guard let sdCardURL else { return } isWorking = true messages = [.info("Prüfung gestartet.")] DispatchQueue.global(qos: .userInitiated).async { let result = SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL) DispatchQueue.main.async { messages = result isWorking = false } } } } private struct MP3GainToolView: View { @State private var targetURL: URL? @State private var messages: [ExportMessage] = [] @State private var isWorking = false private var canRun: Bool { targetURL != nil && !isWorking } var body: some View { VStack(spacing: 0) { HStack(spacing: 10) { Button { chooseFolder(title: "Ordner für MP3Gain wählen") { url in targetURL = url messages = [] } } label: { Label("Ordner", systemImage: "folder") } Spacer() Button { normalizeMP3Gain() } label: { Label("MP3Gain ausführen", systemImage: "waveform") } .buttonStyle(.borderedProminent) .disabled(!canRun) } .padding(12) Divider() VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("MP3Gain Normalisierung") .font(.title2.weight(.semibold)) PathRow(label: "Ordner", url: targetURL) Text("Verwendet rekursiv: mp3gain -r -k -d 4") .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("Protokoll") .font(.headline) MessageList(messages: messages, emptyText: "Noch keine MP3-Dateien normalisiert.") } Spacer(minLength: 0) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } private func normalizeMP3Gain() { guard let targetURL else { return } isWorking = true messages = [.info("MP3Gain gestartet: -r -k -d 4")] DispatchQueue.global(qos: .userInitiated).async { let result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: targetURL) DispatchQueue.main.async { messages = result isWorking = false } } } } private struct ID3TagToolView: View { @State private var targetURL: URL? @State private var messages: [ExportMessage] = [] @State private var isWorking = false @State private var removeAllTags = false @State private var removeTitle = true @State private var removeArtist = true @State private var removeAlbum = true @State private var removeTrack = true @State private var removeYear = true @State private var removeGenre = true @State private var removeComment = true @State private var removeArtwork = true @State private var writeComment = false @State private var commentText = "" @State private var tagSearchText = "" private var canApply: Bool { targetURL != nil && !isWorking && (removeAllTags || selectedFields.isEmpty == false || writeComment) } private var canSearchTags: Bool { targetURL != nil && !isWorking && !tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var selectedFields: Set { var fields = Set() if removeTitle { fields.insert(.title) } if removeArtist { fields.insert(.artist) } if removeAlbum { fields.insert(.album) } if removeTrack { fields.insert(.track) } if removeYear { fields.insert(.year) } if removeGenre { fields.insert(.genre) } if removeComment && !writeComment { fields.insert(.comment) } if removeArtwork { fields.insert(.artwork) } return fields } var body: some View { VStack(spacing: 0) { HStack(spacing: 10) { Button { chooseFolder(title: "SD-Karte oder Ordner wählen") { url in targetURL = url scanTags(for: url) } } label: { Label("Ordner wählen", systemImage: "folder") } Spacer() Button { if let targetURL { scanTags(for: targetURL) } } label: { Label("Tags anzeigen", systemImage: "list.bullet.rectangle") } .disabled(targetURL == nil || isWorking) Button { applyChanges() } label: { Label("Tags anwenden", systemImage: "tag.slash") } .buttonStyle(.borderedProminent) .disabled(!canApply) } .padding(12) Divider() HSplitView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("ID3-Tags bearbeiten") .font(.title2.weight(.semibold)) PathRow(label: "Ordner", url: targetURL) } VStack(alignment: .leading, spacing: 10) { Toggle("Alle ID3-Tags löschen", isOn: $removeAllTags) .toggleStyle(.checkbox) Divider() Text("Felder leeren/löschen") .font(.headline) Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { GridRow { Toggle("Titel", isOn: $removeTitle) Toggle("Interpret", isOn: $removeArtist) } GridRow { Toggle("Album", isOn: $removeAlbum) Toggle("Tracknummer", isOn: $removeTrack) } GridRow { Toggle("Jahr", isOn: $removeYear) Toggle("Genre", isOn: $removeGenre) } GridRow { Toggle("Kommentar", isOn: $removeComment) .disabled(writeComment) Toggle("Cover/Bilder", isOn: $removeArtwork) } } .toggleStyle(.checkbox) .disabled(removeAllTags) Divider() Toggle("Kommentar eintragen", isOn: $writeComment) .toggleStyle(.checkbox) .onChange(of: writeComment) { newValue in if newValue { removeComment = false } } HStack(spacing: 8) { TextField("", text: $commentText) .textFieldStyle(.roundedBorder) .frame(width: 240) Button { useClipboardAsComment() } label: { Label("Aus Zwischenablage", systemImage: "doc.on.clipboard") } Button { useSelectedFolderNameAsComment() } label: { Label("Ordnername", systemImage: "folder") } Button { clearComment() } label: { Label("Leeren", systemImage: "xmark.circle") } } Divider() Text("Tag-Suche") .font(.headline) HStack(spacing: 8) { TextField("", text: $tagSearchText) .textFieldStyle(.roundedBorder) .frame(width: 240) Button { searchTags() } label: { Label("Tags suchen", systemImage: "magnifyingglass") } .disabled(!canSearchTags) } } Spacer(minLength: 0) } .padding(16) .frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) VStack(alignment: .leading, spacing: 12) { Text("Protokoll") .font(.headline) MessageList(messages: messages, emptyText: "Noch keine ID3-Tags angezeigt oder bearbeitet.") } .frame(minWidth: 300) .padding(16) } } } private func applyChanges() { guard let targetURL else { return } isWorking = true messages = [.info("ID3-Bearbeitung gestartet.")] let options = ID3TagEditOptions( removeAllTags: removeAllTags, fieldsToRemove: selectedFields, commentToWrite: writeComment ? commentText.trimmingCharacters(in: .whitespacesAndNewlines) : nil ) DispatchQueue.global(qos: .userInitiated).async { var result = ID3TagEditor(fileManager: .default).apply(to: targetURL, options: options) result.append(contentsOf: ID3TagInspector(fileManager: .default).inspect(rootURL: targetURL)) DispatchQueue.main.async { messages = result isWorking = false } } } private func scanTags(for url: URL) { isWorking = true messages = [.info("ID3-Tags werden gelesen.")] DispatchQueue.global(qos: .userInitiated).async { let result = ID3TagInspector(fileManager: .default).inspect(rootURL: url) DispatchQueue.main.async { messages = result isWorking = false } } } private func searchTags() { guard let targetURL else { return } isWorking = true messages = [.info("Tag-Suche gestartet.")] let query = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines) DispatchQueue.global(qos: .userInitiated).async { let result = ID3TagSearcher(fileManager: .default).search(rootURL: targetURL, query: query) DispatchQueue.main.async { messages = result isWorking = false } } } private func useClipboardAsComment() { guard let text = NSPasteboard.general.string(forType: .string)? .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { messages = [.warning("Zwischenablage enthält keinen Text.")] return } setComment(text) } private func useSelectedFolderNameAsComment() { guard let targetURL else { messages = [.warning("Kein Ordner gewählt.")] return } setComment(targetURL.lastPathComponent) } private func clearComment() { commentText = "" writeComment = false } private func setComment(_ text: String) { commentText = text writeComment = true removeComment = false } } private struct MessageList: View { let messages: [ExportMessage] let emptyText: String var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { if messages.isEmpty { Text(emptyText) .foregroundStyle(.secondary) } else { ForEach(messages) { message in HStack(alignment: .top, spacing: 8) { Image(systemName: message.iconName) .foregroundStyle(message.color) .frame(width: 18) Text(message.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } } } .frame(maxWidth: .infinity, alignment: .leading) } } } private func chooseFolder(title: String, completion: (URL) -> Void) { let panel = NSOpenPanel() panel.title = title panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true if panel.runModal() == .OK, let url = panel.url { completion(url) } } private struct PathRow: View { let label: String let url: URL? var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 48, alignment: .leading) Text(url?.path(percentEncoded: false) ?? "Nicht gewählt") .font(.caption) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) } } } struct ExportMessage: Identifiable { enum Kind { case info case warning case error } let id = UUID() let kind: Kind let text: String var iconName: String { switch kind { case .info: "checkmark.circle" case .warning: "exclamationmark.triangle" case .error: "xmark.octagon" } } var color: Color { switch kind { case .info: .green case .warning: .orange case .error: .red } } static func info(_ text: String) -> ExportMessage { ExportMessage(kind: .info, text: text) } static func warning(_ text: String) -> ExportMessage { ExportMessage(kind: .warning, text: text) } static func error(_ text: String) -> ExportMessage { ExportMessage(kind: .error, text: text) } }