SwiftUI-Oberfläche: MVP mit Drag & Drop, Einstellungen, Dateiliste

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
Samuel Zielke 1 month ago
parent b2e1370ddb
commit 942b339968

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

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,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.")
}
}
}

@ -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)
}
}
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.