diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..64dc11e --- /dev/null +++ b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..00031a3 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..19c79b3 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..61095c3 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..a9ae93b Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..19c79b3 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..09c8388 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..a9ae93b Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..f1ba35a Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..09c8388 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..a4f3e05 Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Sources/DropTwo/Assets.xcassets/Contents.json b/Sources/DropTwo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/DropTwo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DropTwo/DropTwoApp.swift b/Sources/DropTwo/DropTwoApp.swift new file mode 100644 index 0000000..156fe9c --- /dev/null +++ b/Sources/DropTwo/DropTwoApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct DropTwoApp: App { + @StateObject private var settings = AppSettings() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(settings) + } + } +} diff --git a/Sources/DropTwo/Info.plist b/Sources/DropTwo/Info.plist new file mode 100644 index 0000000..5370cd8 --- /dev/null +++ b/Sources/DropTwo/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + drop.two + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + drop.two + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + (c) Samuel Zielke + + diff --git a/Sources/DropTwo/Model/ConversionCoordinator.swift b/Sources/DropTwo/Model/ConversionCoordinator.swift new file mode 100644 index 0000000..18668c4 --- /dev/null +++ b/Sources/DropTwo/Model/ConversionCoordinator.swift @@ -0,0 +1,102 @@ +import DropTwoCore +import Foundation + +/// Treibt die Verarbeitung der Dateiliste über `JobQueue` an und hält den +/// UI-Status pro Datei nach. Kennt keine Views — nur `@Published`-Zustand, +/// den SwiftUI beobachtet. +@MainActor +final class ConversionCoordinator: ObservableObject { + @Published private(set) var items: [DropItem] = [] + @Published private(set) var isRunning = false + + private var runningTask: Task? + + var overallProgress: Double { + guard !items.isEmpty else { return 0 } + let sum = items.reduce(0.0) { partial, item in + switch item.status { + case .waiting: + return partial + case .running(let progress): + return partial + progress + case .skipped, .rejected, .succeeded, .failed: + return partial + 1 + } + } + return sum / Double(items.count) + } + + var problemItems: [DropItem] { + items.filter { $0.status.needsAttention } + } + + /// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst. + func addFiles(_ urls: [URL]) { + let existing = Set(items.map(\.sourceURL)) + let newURLs = urls + .filter { !existing.contains($0) } + .sorted { modificationDate(for: $0) > modificationDate(for: $1) } + items.append(contentsOf: newURLs.map(DropItem.init)) + } + + func removeAll() { + guard !isRunning else { return } + items.removeAll() + } + + func start(targetFolder: URL) { + guard !isRunning, !items.isEmpty else { return } + isRunning = true + + for index in items.indices { + items[index].status = .waiting + } + let jobs = items.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) } + + runningTask = Task { + let stream = JobQueue.run( + jobs: jobs, + outputRootDirectory: targetFolder, + progress: { [weak self] job, value in + Task { @MainActor in + self?.updateProgress(jobID: job.id, value: value) + } + } + ) + for await result in stream { + apply(result) + } + isRunning = false + } + } + + private func updateProgress(jobID: UUID, value: Double) { + guard let index = items.firstIndex(where: { $0.id == jobID }) else { return } + items[index].status = .running(progress: value) + } + + private func apply(_ result: JobQueue.JobResult) { + guard let index = items.firstIndex(where: { $0.id == result.job.id }) else { return } + switch result.outcome { + case .skipped(_, let reason): + items[index].status = .skipped(reason) + case .rejected(_, let reason): + items[index].status = .rejected(reason) + case .succeeded(let success): + items[index].status = .succeeded(croppedDestination: success.croppedDestination) + case .failed(_, let error): + items[index].status = .failed(message: Self.message(for: error)) + } + } + + private static func message(for error: Error) -> String { + if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { + return description + } + return error.localizedDescription + } + + private func modificationDate(for url: URL) -> Date { + (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + } +} diff --git a/Sources/DropTwo/Model/DropItem.swift b/Sources/DropTwo/Model/DropItem.swift new file mode 100644 index 0000000..6411294 --- /dev/null +++ b/Sources/DropTwo/Model/DropItem.swift @@ -0,0 +1,39 @@ +import DropTwoCore +import Foundation + +/// Ein Element in der Dateiliste der Oberfläche, mit seinem aktuellen +/// Verarbeitungsstatus. Rein UI-seitig — die eigentliche Logik steckt in +/// `ConversionPipeline`/`JobQueue` in DropTwoCore. +struct DropItem: Identifiable { + let id: UUID + let sourceURL: URL + var status: Status + + enum Status { + case waiting + case running(progress: Double) + case skipped(SkipReason) + case rejected(RejectionReason) + case succeeded(croppedDestination: URL) + case failed(message: String) + + /// Fehlerliste statt stiller Abbrüche: alles außer "läuft"/"fertig" + /// braucht sichtbare Aufmerksamkeit. + var needsAttention: Bool { + switch self { + case .skipped, .rejected, .failed: + return true + case .waiting, .running, .succeeded: + return false + } + } + } + + init(sourceURL: URL) { + self.id = UUID() + self.sourceURL = sourceURL + self.status = .waiting + } + + var fileName: String { sourceURL.lastPathComponent } +} diff --git a/Sources/DropTwo/Settings/AppSettings.swift b/Sources/DropTwo/Settings/AppSettings.swift new file mode 100644 index 0000000..21ea182 --- /dev/null +++ b/Sources/DropTwo/Settings/AppSettings.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Quell- und Zielordner, persistiert über Security-Scoped Bookmarks statt +/// roher Pfade — überlebt damit auch einen späteren Wechsel in den App +/// Sandbox (README: "@AppStorage + Security-Scoped Bookmarks"). +@MainActor +final class AppSettings: ObservableObject { + @Published private(set) var sourceFolder: URL? + @Published private(set) var targetFolder: URL? + + private enum DefaultsKey { + static let sourceBookmark = "dev.zielke.droptwo.sourceFolderBookmark" + static let targetBookmark = "dev.zielke.droptwo.targetFolderBookmark" + } + + init() { + sourceFolder = Self.resolveBookmark(forKey: DefaultsKey.sourceBookmark) + targetFolder = Self.resolveBookmark(forKey: DefaultsKey.targetBookmark) + } + + func setSourceFolder(_ url: URL) { + sourceFolder = url + Self.storeBookmark(for: url, key: DefaultsKey.sourceBookmark) + } + + func setTargetFolder(_ url: URL) { + targetFolder = url + Self.storeBookmark(for: url, key: DefaultsKey.targetBookmark) + } + + private static func storeBookmark(for url: URL, key: String) { + do { + let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + UserDefaults.standard.set(data, forKey: key) + } catch { + // Best effort: der Ordner bleibt für die laufende Sitzung trotzdem + // gesetzt, nur der Neustart-Fall geht dann verloren. + } + } + + private static func resolveBookmark(forKey key: String) -> URL? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + var isStale = false + guard let url = try? URL(resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) else { + return nil + } + _ = url.startAccessingSecurityScopedResource() + return url + } +} diff --git a/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift b/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift new file mode 100644 index 0000000..a91d26a --- /dev/null +++ b/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift @@ -0,0 +1,53 @@ +import DropTwoCore +import Foundation + +// DropTwoCore hält seine Enums bewusst textfrei (siehe FileNameParser). +// Die deutschen Klartexte für die Oberfläche leben hier — das CLI-Target +// formuliert für dieselben Fälle knapper/technischer in seinen eigenen +// `print`-Aufrufen. + +extension SkipReason: LocalizedError { + public var errorDescription: String? { + switch self { + case .alreadyTargetFormat: + return String(localized: "Ist bereits im 4:5-Format.") + } + } +} + +extension RejectionReason: LocalizedError { + public var errorDescription: String? { + switch self { + case .finderDuplicate: + return String(localized: "Sieht nach einem Finder-Duplikat aus (z. B. „Name (1)“) — bitte die richtige Originaldatei wählen.") + case .formatSegmentMissing: + return String(localized: "Kein 9x16- oder 4x5-Format-Segment im Dateinamen gefunden.") + } + } +} + +extension VideoInspectorError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noVideoTrack: + return String(localized: "Die Datei enthält keine Videospur.") + case .assetUnreadable: + return String(localized: "Die Datei konnte nicht gelesen werden.") + } + } +} + +extension CropEngineError: LocalizedError { + public var errorDescription: String? { + switch self { + case .sourceNotNineBySixteen(let actualSize): + return String(localized: "Kein 9:16-Seitenverhältnis (\(Int(actualSize.width))×\(Int(actualSize.height))).") + case .destinationAlreadyExists: + return String(localized: "Die Zieldatei existiert bereits.") + case .unsupportedContainer: + return String(localized: "Nicht unterstütztes Zielformat — es wird immer MP4 erzeugt.") + case .readerSetupFailed, .writerSetupFailed, .processingFailed: + return String(localized: "Die Datei konnte nicht verarbeitet werden.") + } + } +} diff --git a/Sources/DropTwo/Views/ContentView.swift b/Sources/DropTwo/Views/ContentView.swift new file mode 100644 index 0000000..82e278d --- /dev/null +++ b/Sources/DropTwo/Views/ContentView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var settings: AppSettings + @StateObject private var coordinator = ConversionCoordinator() + @State private var showSettings = false + + var body: some View { + VStack(spacing: 16) { + DropZoneView() + + if !coordinator.items.isEmpty { + FileListView() + + if !coordinator.problemItems.isEmpty { + Text("\(coordinator.problemItems.count) Datei(en) brauchen Aufmerksamkeit.") + .font(.footnote) + .foregroundStyle(.orange) + } + + HStack { + ProgressView(value: coordinator.overallProgress) + Button("Leeren") { coordinator.removeAll() } + .disabled(coordinator.isRunning) + } + } + + Divider() + + HStack { + Button("Einstellungen…") { showSettings = true } + Spacer() + Button("Start") { + guard let target = settings.targetFolder else { return } + coordinator.start(targetFolder: target) + } + .keyboardShortcut(.defaultAction) + .disabled(coordinator.items.isEmpty || coordinator.isRunning || settings.targetFolder == nil) + } + } + .padding(20) + .frame(minWidth: 520, minHeight: 460) + .environmentObject(coordinator) + .sheet(isPresented: $showSettings) { + SettingsView() + } + } +} diff --git a/Sources/DropTwo/Views/DropZoneView.swift b/Sources/DropTwo/Views/DropZoneView.swift new file mode 100644 index 0000000..2f7e122 --- /dev/null +++ b/Sources/DropTwo/Views/DropZoneView.swift @@ -0,0 +1,56 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct DropZoneView: View { + @EnvironmentObject private var settings: AppSettings + @EnvironmentObject private var coordinator: ConversionCoordinator + @State private var isTargeted = false + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("9:16-Dateien hierher ziehen") + .font(.headline) + Button("Datei(en) wählen…", action: chooseFiles) + } + .frame(maxWidth: .infinity, minHeight: 140) + .background( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6])) + .foregroundStyle(isTargeted ? Color.accentColor : Color.secondary.opacity(0.35)) + ) + .onDrop(of: [.fileURL], isTargeted: $isTargeted, perform: handleDrop) + } + + private func chooseFiles() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie] + if let sourceFolder = settings.sourceFolder { + panel.directoryURL = sourceFolder + } + guard panel.runModal() == .OK else { return } + coordinator.addFiles(panel.urls) + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard !providers.isEmpty else { return false } + let group = DispatchGroup() + var urls: [URL] = [] + for provider in providers { + group.enter() + _ = provider.loadObject(ofClass: URL.self) { url, _ in + if let url { urls.append(url) } + group.leave() + } + } + group.notify(queue: .main) { + coordinator.addFiles(urls) + } + return true + } +} diff --git a/Sources/DropTwo/Views/FileListView.swift b/Sources/DropTwo/Views/FileListView.swift new file mode 100644 index 0000000..652ee42 --- /dev/null +++ b/Sources/DropTwo/Views/FileListView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct FileListView: View { + @EnvironmentObject private var coordinator: ConversionCoordinator + + var body: some View { + List(coordinator.items) { item in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.fileName) + .lineLimit(1) + .truncationMode(.middle) + statusText(for: item.status) + .font(.caption) + .foregroundStyle(item.status.needsAttention ? Color.orange : .secondary) + } + Spacer() + statusIndicator(for: item.status) + } + .padding(.vertical, 2) + } + } + + @ViewBuilder + private func statusText(for status: DropItem.Status) -> some View { + switch status { + case .waiting: + Text("Wartet …") + case .running(let progress): + Text("\(Int(progress * 100)) %") + case .skipped(let reason): + Text(reason.errorDescription ?? "Übersprungen") + case .rejected(let reason): + Text(reason.errorDescription ?? "Abgelehnt") + case .succeeded(let destination): + Text("Fertig — \(destination.lastPathComponent)") + case .failed(let message): + Text(message) + } + } + + @ViewBuilder + private func statusIndicator(for status: DropItem.Status) -> some View { + switch status { + case .waiting: + Image(systemName: "clock") + .foregroundStyle(.secondary) + case .running(let progress): + ProgressView(value: progress) + .frame(width: 60) + case .skipped: + Image(systemName: "arrow.uturn.forward.circle") + .foregroundStyle(.secondary) + case .rejected, .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + case .succeeded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } +} diff --git a/Sources/DropTwo/Views/SettingsView.swift b/Sources/DropTwo/Views/SettingsView.swift new file mode 100644 index 0000000..905200d --- /dev/null +++ b/Sources/DropTwo/Views/SettingsView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var settings: AppSettings + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Einstellungen") + .font(.title2.bold()) + + VStack(alignment: .leading, spacing: 6) { + Text("Quellordner").font(.headline) + Text("Der Dateiauswahl-Dialog öffnet sich hier.") + .font(.caption) + .foregroundStyle(.secondary) + folderRow(current: settings.sourceFolder) { settings.setSourceFolder($0) } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Zielordner").font(.headline) + Text("Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.") + .font(.caption) + .foregroundStyle(.secondary) + folderRow(current: settings.targetFolder) { settings.setTargetFolder($0) } + } + + Spacer() + + HStack { + Spacer() + Button("Fertig") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: 440, minHeight: 280) + } + + @ViewBuilder + private func folderRow(current: URL?, onChoose: @escaping (URL) -> Void) -> some View { + HStack { + Text(current?.path ?? "Nicht gesetzt") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(current == nil ? .secondary : .primary) + Spacer() + Button("Wählen…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + if let current { panel.directoryURL = current } + guard panel.runModal() == .OK, let url = panel.url else { return } + onChoose(url) + } + } + } +}