Schritt 4 der Umsetzungsreihenfolge. - AppSettings: Quell-/Zielordner über Security-Scoped Bookmarks, überlebt einen Neustart. - DropZoneView: Drag & Drop plus Dateiauswahl-Dialog, öffnet im konfigurierten Quellordner. - ConversionCoordinator treibt JobQueue an, sortiert neue Dateien nach zuletzt geändert, hält Fortschritt/Status pro Datei nach. - FileListView zeigt Fortschritt und hebt Problemfälle (übersprungen/ abgelehnt/fehlgeschlagen) sichtbar hervor — keine stillen Abbrüche. - CoreErrors+LocalizedError: deutsche Klartexte für die Core-Fehler- Enums leben bewusst im App-Target, nicht im UI-freien Core. - AppIcon aus assets/icon.svg gebaut (alle 10 macOS-Icongrößen via rsvg-convert, ImageMagicks eingebauter SVG-Renderer hat den Rahmen-Stroke verschluckt). Bewusst ausgeklammert (spätere Schritte): Review-Ansicht, Menu-Bar- Betrieb, Signierung/Notarisierung/Sparkle, Overwrite-Rückfrage-Dialog.main
@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct DropTwoApp: App {
|
||||
@StateObject private var settings = AppSettings()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>drop.two</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>drop.two</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>(c) Samuel Zielke</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||