App: natives Fenster, Ausschnitt-Editor, Hinweise statt stiller Abbrüche, Sparkle

- Systemfenster mit Toolbar (Dateien, Zielordner, Start), Tabelle mit
  Standbild samt Ausschnitt, Ausschnitt-Knopf, Status als Text; das ganze
  Fenster nimmt Dateien an. Folgt hell/dunkel des Systems, Gold nur als Akzent.
- Ausschnitt-Ansicht: Rahmen ziehen, Mittig setzen (Z/Doppelklick), Marken
  setzen (M), per Hover-×, Rechtsklick oder ⌫ löschen, eigene Zeitleiste.
- Zielordner immer sichtbar; Start fragt ihn ab, statt still ausgegraut zu sein.
- Fehlendes Format im Namen wird beim Reinziehen gezeigt, mit "4x5 anhängen"
  pro Datei oder für alle.
- Sparkle-Updater wird jetzt tatsächlich gestartet (vorher nur eingebunden),
  plus "Nach Updates suchen …" im App-Menü.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShKCZzkdZhym6mHuPqXQAT
main
Samuel Zielke 2 weeks ago
parent 8547621cac
commit 4276d6feb3

@ -3,11 +3,21 @@ import SwiftUI
@main
struct DropTwoApp: App {
@StateObject private var settings = AppSettings()
@StateObject private var updater = Updater()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(settings)
.tint(.gold)
}
.windowToolbarStyle(.unified)
.defaultSize(width: 820, height: 520)
.commands {
CommandGroup(after: .appInfo) {
Button("Nach Updates suchen …") { updater.checkForUpdates() }
.disabled(!updater.canCheckForUpdates)
}
}
}
}

@ -34,6 +34,20 @@ final class ConversionCoordinator: ObservableObject {
items.filter { $0.status.needsAttention }
}
/// Dateien ohne Format im Namen, für die noch keine Wahl getroffen ist.
var itemsMissingFormat: [DropItem] {
items.filter { $0.isMissingFormat }
}
var finishedCount: Int {
items.filter {
switch $0.status {
case .waiting, .running: return false
default: return true
}
}.count
}
/// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst.
func addFiles(_ urls: [URL]) {
let existing = Set(items.map(\.sourceURL))
@ -43,6 +57,28 @@ final class ConversionCoordinator: ObservableObject {
items.append(contentsOf: newURLs.map(DropItem.init))
}
func setCropPlan(_ plan: CropPlan, for itemID: UUID) {
guard let index = items.firstIndex(where: { $0.id == itemID }) else { return }
items[index].cropPlan = plan
}
/// "4x5" an den Namen anhängen, wo kein Format drinsteht. Setzt einen
/// schon abgelehnten Eintrag zurück auf "wartet".
func setAppendFormat(_ append: Bool, for itemIDs: [UUID]) {
for id in itemIDs {
guard let index = items.firstIndex(where: { $0.id == id }) else { continue }
items[index].appendFormatWhenMissing = append
if case .rejected(.formatSegmentMissing) = items[index].status {
items[index].status = .waiting
}
}
}
func remove(_ itemID: UUID) {
guard !isRunning else { return }
items.removeAll { $0.id == itemID }
}
func removeAll() {
guard !isRunning else { return }
items.removeAll()
@ -55,7 +91,11 @@ final class ConversionCoordinator: ObservableObject {
guard !isRunning, !items.isEmpty else { return }
let conflicts = items.compactMap { item -> OverwriteConflict? in
guard let destination = ConversionPipeline.previewDestination(sourceURL: item.sourceURL, outputRootDirectory: targetFolder),
guard let destination = ConversionPipeline.previewDestination(
sourceURL: item.sourceURL,
outputRootDirectory: targetFolder,
appendFormatWhenMissing: item.appendFormatWhenMissing
),
FileManager.default.fileExists(atPath: destination.path) else {
return nil
}
@ -95,7 +135,14 @@ final class ConversionCoordinator: ObservableObject {
private func runBatch(targetFolder: URL, overwrite: Bool, skipping skipIDs: Set<UUID>) {
let jobs = items
.filter { !skipIDs.contains($0.id) }
.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) }
.map {
JobQueue.Job(
id: $0.id,
sourceURL: $0.sourceURL,
cropPlan: $0.cropPlan,
appendFormatWhenMissing: $0.appendFormatWhenMissing
)
}
guard !jobs.isEmpty else { return }
isRunning = true

@ -8,6 +8,10 @@ struct DropItem: Identifiable {
let id: UUID
let sourceURL: URL
var status: Status
/// Ausschnitt für diese Datei, in der Review-Ansicht eingestellt.
var cropPlan = CropPlan()
/// Nutzer hat bei fehlendem Format im Namen "4x5 anhängen" gewählt.
var appendFormatWhenMissing = false
enum Status {
case waiting
@ -39,4 +43,27 @@ struct DropItem: Identifiable {
}
var fileName: String { sourceURL.lastPathComponent }
/// Was der Dateiname vor dem Lauf schon verrät — so sieht man Probleme
/// beim Reinziehen, nicht erst nach dem Start.
var nameOutcome: FileNameParseResult.Outcome {
FileNameParser.parse(fileName).outcome
}
/// true = im Namen steht kein Format, und der Nutzer hat noch nicht
/// entschieden, "4x5" anzuhängen.
var isMissingFormat: Bool {
guard !appendFormatWhenMissing, case .rejected(.formatSegmentMissing) = nameOutcome else { return false }
return true
}
/// Name der 4:5-Fassung, wie er entstehen wird. nil = wird so nicht verarbeitet.
var plannedTargetName: String? {
let root = URL(fileURLWithPath: "/", isDirectory: true)
return ConversionPipeline.previewDestination(
sourceURL: sourceURL,
outputRootDirectory: root,
appendFormatWhenMissing: appendFormatWhenMissing
)?.lastPathComponent
}
}

@ -0,0 +1,33 @@
import AppKit
import UniformTypeIdentifiers
enum FileImport {
/// Dateiauswahl für Videos, startet im Quellordner. Leer = abgebrochen.
@MainActor
static func chooseVideos(startingAt sourceFolder: URL?) -> [URL] {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie]
if let sourceFolder { panel.directoryURL = sourceFolder }
guard panel.runModal() == .OK else { return [] }
return panel.urls
}
/// Liest die Datei-URLs aus einem Drop und liefert sie gesammelt auf dem Main-Thread.
static func loadURLs(from providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) {
let group = DispatchGroup()
var urls: [URL] = []
for provider in providers {
group.enter()
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url {
DispatchQueue.main.async { urls.append(url) }
}
group.leave()
}
}
group.notify(queue: .main) { completion(urls) }
}
}

@ -0,0 +1,17 @@
import AppKit
enum FolderPicker {
/// Ordnerauswahl, startet bei `current`. nil = abgebrochen.
@MainActor
static func choose(startingAt current: URL?, prompt: String = String(localized: "Wählen")) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = prompt
if let current { panel.directoryURL = current }
guard panel.runModal() == .OK else { return nil }
return panel.url
}
}

@ -0,0 +1,39 @@
import AppKit
import SwiftUI
/// Gestaltung: ein normales Mac-Fenster in Systemfarben. Gold ist der einzige
/// Akzent (README, "Gestaltung") und markiert nur zwei Dinge: die Hauptaktion
/// und den Ausschnitt.
extension Color {
/// #C8A96A im Dunkeln; im Hellen dunkler, damit Text darauf lesbar bleibt.
static let gold = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? NSColor(srgbRed: 0.784, green: 0.663, blue: 0.416, alpha: 1)
: NSColor(srgbRed: 0.604, green: 0.482, blue: 0.235, alpha: 1)
})
/// Schrift auf Gold: dunkel auf hellem Gold, weiß auf dunklem Gold.
static let onGold = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? NSColor(srgbRed: 0.043, green: 0.043, blue: 0.047, alpha: 1)
: .white
})
static let attention = Color(nsColor: .systemOrange)
}
/// Die eine Hauptaktion eines Fensters (Start, Übernehmen).
struct ProminentButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isEnabled ? Color.onGold : Color.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isEnabled ? Color.gold.opacity(configuration.isPressed ? 0.8 : 1) : Color.secondary.opacity(0.15))
)
.contentShape(Rectangle())
}
}

@ -0,0 +1,21 @@
import Combine
import Sparkle
/// Sparkle-Updates gegen `SUFeedURL` aus der Info.plist: automatische Prüfung
/// im Hintergrund plus „Nach Updates suchen …“ im App-Menü.
final class Updater: ObservableObject {
@Published private(set) var canCheckForUpdates = false
private let controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
private var cancellable: AnyCancellable?
init() {
cancellable = controller.updater.publisher(for: \.canCheckForUpdates)
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.canCheckForUpdates = $0 }
}
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}

@ -4,43 +4,43 @@ struct ContentView: View {
@EnvironmentObject private var settings: AppSettings
@StateObject private var coordinator = ConversionCoordinator()
@State private var showSettings = false
@State private var isDropTargeted = false
var body: some View {
VStack(spacing: 16) {
DropZoneView()
if !coordinator.items.isEmpty {
VStack(spacing: 0) {
if coordinator.items.isEmpty {
emptyState
} else {
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)
statusBar
}
.frame(minWidth: 680, minHeight: 420)
.navigationTitle("drop.two")
.toolbar { toolbarContent }
.overlay {
if isDropTargeted {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.gold, lineWidth: 2)
.padding(4)
.allowsHitTesting(false)
}
}
.padding(20)
.frame(minWidth: 520, minHeight: 460)
.onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
FileImport.loadURLs(from: providers) { coordinator.addFiles($0) }
return true
}
.environmentObject(coordinator)
#if DEBUG
// Nur zum Ausprobieren: DROPTWO_DEBUG_FILES="/a.mp4:/b.mov" lädt
// Dateien beim Start, ohne Dialog.
.onAppear {
if let paths = ProcessInfo.processInfo.environment["DROPTWO_DEBUG_FILES"] {
coordinator.addFiles(paths.split(separator: ":").map { URL(fileURLWithPath: String($0)) })
}
}
#endif
.sheet(isPresented: $showSettings) {
SettingsView()
}
@ -61,6 +61,131 @@ struct ContentView: View {
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: addFiles) {
Label("Dateien hinzufügen", systemImage: "plus")
}
.help("Videos hinzufügen (⌘O)")
.keyboardShortcut("o")
Button(action: chooseTargetFolder) {
Label {
Text(settings.targetFolder?.lastPathComponent ?? String(localized: "Zielordner wählen …"))
} icon: {
Image(systemName: "folder")
}
.labelStyle(.titleAndIcon)
}
.help(settings.targetFolder.map { "Zielordner: \($0.path)" } ?? String(localized: "Hier entsteht pro Projekt ein Unterordner mit beiden Fassungen."))
Button("Start", action: start)
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
.disabled(coordinator.items.isEmpty || coordinator.isRunning)
}
ToolbarItem(placement: .automatic) {
Button { showSettings = true } label: {
Label("Einstellungen", systemImage: "gearshape")
}
.help("Einstellungen")
}
}
// MARK: - Leerer Zustand
/// Kein Symbolbild, keine Box: nur ein Satz. Das ganze Fenster nimmt Dateien an.
private var emptyState: some View {
VStack(spacing: 10) {
Text("9:16-Videos hierher ziehen.")
.font(.system(size: 22, weight: .medium))
HStack(spacing: 4) {
Text("Oder")
Button("Dateien wählen …", action: addFiles)
.buttonStyle(.link)
.tint(.gold)
Text("· Daraus wird je eine 4:5-Fassung im Projektordner.")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Statusleiste
private var statusBar: some View {
HStack(spacing: 6) {
if coordinator.items.isEmpty {
Text(settings.targetFolder.map { "Ziel: \($0.path)" } ?? String(localized: "Noch kein Zielordner gewählt."))
.foregroundStyle(settings.targetFolder == nil ? Color.attention : .secondary)
} else {
Text(summary)
.foregroundStyle(.secondary)
if settings.targetFolder == nil {
Text("· Kein Zielordner – wird beim Start abgefragt.")
.foregroundStyle(Color.attention)
}
let missing = coordinator.itemsMissingFormat
if missing.count > 1 && !coordinator.isRunning {
Text("·").foregroundStyle(.secondary)
Button("Bei allen \(missing.count) ohne Format „4x5“ anhängen") {
coordinator.setAppendFormat(true, for: missing.map(\.id))
}
.buttonStyle(.link)
.tint(.gold)
}
}
Spacer()
if !coordinator.items.isEmpty {
Button("Liste leeren") { coordinator.removeAll() }
.buttonStyle(.link)
.foregroundStyle(.secondary)
.disabled(coordinator.isRunning)
}
}
.font(.system(size: 11))
.lineLimit(1)
.padding(.horizontal, 16)
.frame(height: 28)
.background(.bar)
}
private var summary: String {
let total = coordinator.items.count
let done = coordinator.finishedCount
let files = total == 1 ? String(localized: "1 Datei") : String(localized: "\(total) Dateien")
if coordinator.isRunning {
return String(localized: "\(files) · \(done) fertig · rendert …")
}
return done > 0
? String(localized: "\(files) · \(done) fertig")
: files
}
// MARK: - Aktionen
private func addFiles() {
coordinator.addFiles(FileImport.chooseVideos(startingAt: settings.sourceFolder))
}
private func chooseTargetFolder() {
if let url = FolderPicker.choose(startingAt: settings.targetFolder, prompt: String(localized: "Als Zielordner wählen")) {
settings.setTargetFolder(url)
}
}
private func start() {
if settings.targetFolder == nil {
chooseTargetFolder()
}
guard let target = settings.targetFolder else { return }
coordinator.start(targetFolder: target)
}
private var conflictMessage: String {
let names = coordinator.pendingConflicts.map(\.fileName).joined(separator: ", ")
return "Für folgende Datei(en) existiert die Zieldatei schon: \(names). Ersetzen oder überspringen?"

@ -1,56 +0,0 @@
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
}
}

@ -1,81 +1,256 @@
import AVFoundation
import DropTwoCore
import SwiftUI
/// Die Dateien als schlichte Tabelle: Standbild mit Ausschnitt · Datei ·
/// wird zu · Status. Klick aufs Standbild öffnet die Ausschnitt-Ansicht.
struct FileListView: View {
@EnvironmentObject private var coordinator: ConversionCoordinator
@State private var previewTarget: PreviewTarget?
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)
VStack(spacing: 0) {
header
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(coordinator.items) { item in
FileRow(item: item) {
previewTarget = PreviewTarget(itemID: item.id)
}
Divider().padding(.leading, 16)
}
}
Spacer()
Button {
previewTarget = PreviewTarget(url: item.sourceURL)
} label: {
Image(systemName: "eye")
}
.buttonStyle(.borderless)
.help("Vorschau mit 4:5-Rahmen zur Framing-Kontrolle")
statusIndicator(for: item.status)
}
.padding(.vertical, 2)
}
#if DEBUG
.onAppear {
if ProcessInfo.processInfo.environment["DROPTWO_DEBUG_REVIEW"] != nil, let first = coordinator.items.last {
previewTarget = PreviewTarget(itemID: first.id)
}
}
#endif
.sheet(item: $previewTarget) { target in
ReviewSheet(sourceURL: target.url)
if let item = coordinator.items.first(where: { $0.id == target.itemID }) {
ReviewSheet(sourceURL: item.sourceURL, initialPlan: item.cropPlan) { plan in
coordinator.setCropPlan(plan, for: item.id)
}
}
}
}
private var header: some View {
HStack(spacing: FileRow.columnSpacing) {
Color.clear.frame(width: FileRow.thumbnailWidth)
Text("Datei").frame(maxWidth: .infinity, alignment: .leading)
Text("wird zu").frame(maxWidth: .infinity, alignment: .leading)
Text("Ausschnitt").frame(width: FileRow.framingWidth, alignment: .leading)
Text("Status").frame(width: FileRow.statusWidth, alignment: .leading)
}
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.frame(height: 26)
}
private struct PreviewTarget: Identifiable {
let url: URL
var id: URL { url }
let itemID: UUID
var id: UUID { itemID }
}
}
private struct FileRow: View {
static let thumbnailWidth: CGFloat = 27
static let statusWidth: CGFloat = 170
static let framingWidth: CGFloat = 130
static let columnSpacing: CGFloat = 16
let item: DropItem
let onReview: () -> Void
@EnvironmentObject private var coordinator: ConversionCoordinator
@State private var thumbnail: CGImage?
var body: some View {
HStack(spacing: Self.columnSpacing) {
Button(action: onReview) {
CropThumbnail(image: thumbnail, offset: item.cropPlan.keyframes[0].offset, dimmed: item.isMissingFormat)
}
.buttonStyle(.plain)
.help("Ausschnitt prüfen und verschieben")
Text(item.fileName)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
targetColumn
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
framingButton
.frame(width: Self.framingWidth, alignment: .leading)
statusColumn
.frame(width: Self.statusWidth, alignment: .leading)
}
.font(.system(size: 13))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(item.isMissingFormat ? Color.attention.opacity(0.07) : Color.clear)
.contentShape(Rectangle())
.contextMenu { contextMenu }
.task(id: item.sourceURL) { thumbnail = await Self.makeThumbnail(for: item.sourceURL) }
}
// MARK: - Spalten
@ViewBuilder
private func statusText(for status: DropItem.Status) -> some View {
switch status {
private var targetColumn: some View {
if let target = item.plannedTargetName {
Text(target).foregroundStyle(.secondary)
} else {
switch item.nameOutcome {
case .skipped:
Text("ist schon 4:5").foregroundStyle(.secondary)
case .rejected(.formatSegmentMissing):
Text("kein Format im Namen").foregroundStyle(Color.attention)
case .rejected(.finderDuplicate):
Text("Finder-Duplikat").foregroundStyle(Color.attention)
case .parsed:
Text("–").foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var statusColumn: some View {
switch item.status {
case .waiting:
Text("Wartet …")
if item.isMissingFormat && !coordinator.isRunning {
Button("„4x5“ anhängen") { coordinator.setAppendFormat(true, for: [item.id]) }
.controlSize(.small)
.help(appendHelp)
} else if item.plannedTargetName == nil {
Text("wird übersprungen").foregroundStyle(.secondary)
} else {
Text("wartet").foregroundStyle(.secondary)
}
case .running(let progress):
Text("\(Int(progress * 100)) %")
HStack(spacing: 8) {
Text("rendert \(Int(progress * 100)) %")
.monospacedDigit()
.foregroundStyle(Color.gold)
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.gold)
.frame(width: 60)
}
case .succeeded(let destination):
HStack(spacing: 4) {
Text("fertig ·")
Button("im Finder") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
.buttonStyle(.link)
.tint(.gold)
}
case .skipped(let reason):
Text(reason.errorDescription ?? "Übersprungen")
Text(reason.errorDescription ?? "übersprungen").foregroundStyle(.secondary).help(reason.errorDescription ?? "")
case .skippedByUser(let reason):
Text("übersprungen").foregroundStyle(.secondary).help(reason)
case .rejected(let reason):
Text(reason.errorDescription ?? "Abgelehnt")
case .succeeded(let destination):
Text("Fertig — \(destination.lastPathComponent)")
Text(reason.errorDescription ?? "abgelehnt").foregroundStyle(Color.attention).help(reason.errorDescription ?? "")
case .failed(let message):
Text(message)
case .skippedByUser(let reason):
Text(reason)
Text(message).foregroundStyle(Color.attention).help(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, .skippedByUser:
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)
private var contextMenu: some View {
Button("Ausschnitt anpassen …", action: onReview)
if item.isMissingFormat {
Button("„4x5“ an den Namen anhängen") { coordinator.setAppendFormat(true, for: [item.id]) }
}
if case .succeeded(let destination) = item.status {
Button("Im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
}
Divider()
Button("Aus der Liste entfernen") { coordinator.remove(item.id) }
.disabled(coordinator.isRunning)
}
private var appendHelp: String {
guard let preview = FileNameParser.appendingTargetFormat(to: item.fileName) else { return "" }
return "Ergibt „\(preview.projectName)/\(ConversionPipeline.mp4Name(preview.targetFileName))“"
}
/// Zeigt, wie der Ausschnitt steht, und öffnet ihn — derselbe Weg wie
/// der Klick aufs Standbild, nur als sichtbarer Knopf.
private var framingButton: some View {
Button(action: onReview) {
Label(framingText, systemImage: "crop")
}
.controlSize(.small)
.tint(item.cropPlan.isDefault ? nil : .gold)
.foregroundStyle(item.cropPlan.isDefault ? Color.primary : Color.gold)
.help("Ausschnitt prüfen und verschieben")
}
private var framingText: String {
let plan = item.cropPlan
if plan.keyframes.count > 1 {
return String(localized: "\(plan.keyframes.count) Positionen")
}
switch plan.keyframes[0].offset {
case 0: return String(localized: "mittig")
case ..<0: return String(localized: "weiter oben")
default: return String(localized: "weiter unten")
}
}
private static func makeThumbnail(for url: URL) async -> CGImage? {
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 120, height: 120)
let time = CMTime(seconds: 0.5, preferredTimescale: 600)
return await withCheckedContinuation { continuation in
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, image, _, _, _ in
continuation.resume(returning: image)
}
}
}
}
/// Mini-Standbild im 9:16-Format mit dem 4:5-Rahmen an seiner Position.
private struct CropThumbnail: View {
let image: CGImage?
let offset: CGFloat
var dimmed = false
private let size = CGSize(width: 27, height: 48)
var body: some View {
let reference = CGSize(width: 1080, height: 1920)
let rect = CropEngine.cropRect(forSourceSize: reference, offset: offset)
let scale = size.height / reference.height
ZStack(alignment: .top) {
Rectangle().fill(Color.secondary.opacity(0.2))
if let image {
Image(decorative: image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size.width, height: size.height)
}
// Abgedunkelt, was wegfällt; Goldrahmen, was bleibt.
VStack(spacing: 0) {
Rectangle().fill(Color.black.opacity(0.5)).frame(height: rect.minY * scale)
Rectangle()
.strokeBorder(Color.gold.opacity(dimmed ? 0.5 : 1), lineWidth: 1.5)
.frame(height: rect.height * scale)
Rectangle().fill(Color.black.opacity(0.5))
}
}
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
}

@ -11,11 +11,12 @@ import SwiftUI
/// direkt per `NSViewRepresentable` einzubinden umgeht den kaputten Codepfad.
struct PlayerView: NSViewRepresentable {
let player: AVPlayer
var controlsStyle: AVPlayerViewControlsStyle = .inline
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = player
view.controlsStyle = .inline
view.controlsStyle = controlsStyle
view.showsFullScreenToggleButton = false
return view
}

@ -2,38 +2,84 @@ import AVKit
import DropTwoCore
import SwiftUI
/// Schritt 5: 9:16-Master mit eingeblendetem 4:5-Rahmen zur Framing-Kontrolle.
/// Scrubbar über die nativen Kontrollen von `PlayerView` (AppKits
/// `AVPlayerView`, nicht AVKits SwiftUI-`VideoPlayer` — siehe dort für den
/// Grund). Der Rahmen nutzt exakt dieselbe Geometrie wie
/// `CropEngine.targetSize` — was hier zu sehen ist, ist tatsächlich das, was
/// beim Crop rauskommt. Rein zur Kontrolle: der Offset ist hier noch nicht
/// verstellbar (README, "Danach"-Liste).
/// 9:16-Master mit eingeblendetem 4:5-Rahmen — zur Framing-Kontrolle und zum
/// Verschieben des Ausschnitts. Der Rahmen lässt sich mit der Maus nach oben
/// oder unten ziehen. Für Clips mit Szenenwechsel setzt man Positionsmarken:
/// ab jeder Marke gilt eine eigene Rahmenposition (`CropPlan`).
///
/// Rahmen und Render nutzen dieselbe Geometrie (`CropEngine.cropRect`) — was
/// hier zu sehen ist, ist tatsächlich das, was beim Crop rauskommt.
///
/// Eigene Transportleiste statt der nativen `AVPlayerView`-Kontrollen: die
/// liegen im Bild und würden mit dem Ziehen des Rahmens kollidieren, und
/// die Marken müssen auf der Zeitleiste sichtbar sein.
struct ReviewSheet: View {
let sourceURL: URL
let onApply: (CropPlan) -> Void
@Environment(\.dismiss) private var dismiss
@State private var plan: CropPlan
@State private var player: AVPlayer?
@State private var videoSize: CGSize?
@State private var info: VideoInfo?
@State private var loadError: String?
@State private var currentTime: Double = 0
@State private var isPlaying = false
@State private var timeObserver: Any?
/// Offset beim Beginn eines Rahmen-Drags; nil = gerade kein Drag.
@State private var dragStartOffset: CGFloat?
/// Marke unter dem Mauszeiger auf der Zeitleiste.
@State private var hoveredKeyframeID: CropPlan.Keyframe.ID?
init(sourceURL: URL, initialPlan: CropPlan, onApply: @escaping (CropPlan) -> Void) {
self.sourceURL = sourceURL
self.onApply = onApply
_plan = State(initialValue: initialPlan)
}
var body: some View {
VStack(spacing: 12) {
HStack {
HStack(spacing: 8) {
Image(systemName: "crop")
.foregroundStyle(Color.gold)
Text(sourceURL.lastPathComponent)
.font(.headline)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Schließen") { dismiss() }
}
.frame(maxWidth: .infinity, alignment: .leading)
content
if let info, player != nil {
transportBar(info: info)
markerBar
}
HStack(spacing: 10) {
Button("Zurücksetzen") { plan = CropPlan() }
.disabled(plan.isDefault)
.help("Mittig, alle Marken entfernen")
Spacer()
Button("Abbrechen", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button("Übernehmen") {
onApply(plan)
dismiss()
}
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
.disabled(info == nil)
}
.padding(.top, 4)
}
.padding(16)
.frame(minWidth: 420, minHeight: 520)
.padding(18)
.frame(minWidth: 500, idealWidth: 540, minHeight: 700, idealHeight: 800)
.task { await load() }
.onDisappear(perform: tearDown)
}
// MARK: - Vorschau
@ViewBuilder
private var content: some View {
if let loadError {
@ -44,50 +90,394 @@ struct ReviewSheet: View {
}
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let player, let videoSize, videoSize.height > 0 {
} else if let player, let info, info.pixelSize.height > 0 {
GeometryReader { proxy in
ZStack {
PlayerView(player: player)
cropOverlay(sourceSize: videoSize, viewSize: proxy.size)
PlayerView(player: player, controlsStyle: .none)
cropOverlay(sourceSize: info.pixelSize, viewSize: proxy.size)
Color.clear
.contentShape(Rectangle())
.gesture(frameDrag(sourceSize: info.pixelSize, viewSize: proxy.size))
.onTapGesture(count: 2, perform: centerFrame)
.onHover { inside in
if inside { NSCursor.openHand.push() } else { NSCursor.pop() }
}
}
}
.aspectRatio(videoSize.width / videoSize.height, contentMode: .fit)
.aspectRatio(info.pixelSize.width / info.pixelSize.height, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.secondary.opacity(0.3))
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ProgressView("Lädt Vorschau …")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Zwei abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt;
/// der helle Rahmen zeigt die bleibende 4:5-Fläche — volle Breite,
/// vertikal zentriert, exakt wie `CropEngine` es tatsächlich schneidet.
/// Abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt; der
/// goldene Rahmen zeigt die bleibende 4:5-Fläche an der Position, die
/// zum aktuellen Zeitpunkt gilt.
@ViewBuilder
private func cropOverlay(sourceSize: CGSize, viewSize: CGSize) -> some View {
let cropSize = CropEngine.targetSize(forSourceSize: sourceSize)
let marginFraction = max(0, (1 - cropSize.height / sourceSize.height) / 2)
let marginHeight = viewSize.height * marginFraction
let rect = CropEngine.cropRect(forSourceSize: sourceSize, offset: plan.offset(at: currentTime))
let scale = viewSize.height / sourceSize.height
let topHeight = rect.minY * scale
let cropHeight = rect.height * scale
let bottomHeight = max(viewSize.height - topHeight - cropHeight, 0)
VStack(spacing: 0) {
Rectangle()
.fill(Color.black.opacity(0.55))
.frame(height: marginHeight)
.frame(height: topHeight)
Rectangle()
.strokeBorder(Color(red: 0.784, green: 0.663, blue: 0.416), lineWidth: 2)
.frame(height: max(viewSize.height - marginHeight * 2, 0))
.strokeBorder(Color.gold, lineWidth: dragStartOffset == nil ? 2 : 3)
.frame(height: cropHeight)
Rectangle()
.fill(Color.black.opacity(0.55))
.frame(height: marginHeight)
.frame(height: bottomHeight)
}
.allowsHitTesting(false)
}
/// Ziehen irgendwo im Bild verschiebt den Rahmen des gerade geltenden
/// Abschnitts. Die Wiedergabe pausiert dabei, sonst wechselt der
/// Abschnitt mitten im Ziehen.
private func frameDrag(sourceSize: CGSize, viewSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
if dragStartOffset == nil {
pause()
dragStartOffset = plan.offset(at: currentTime)
NSCursor.closedHand.push()
}
let cropHeight = CropEngine.targetSize(forSourceSize: sourceSize).height
let slackInView = (sourceSize.height - cropHeight) * viewSize.height / sourceSize.height
guard slackInView > 0, let start = dragStartOffset else { return }
// Offset -1…1 überspannt den gesamten Spielraum, also 2 Einheiten.
plan.setOffset(start + value.translation.height / slackInView * 2, at: currentTime)
}
.onEnded { _ in
dragStartOffset = nil
NSCursor.pop()
}
}
// MARK: - Transport
private func transportBar(info: VideoInfo) -> some View {
let duration = CMTimeGetSeconds(info.duration)
return HStack(spacing: 8) {
Button(action: togglePlayback) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
}
.keyboardShortcut(.space, modifiers: [])
.help("Abspielen/Pause (Leertaste)")
.buttonStyle(.borderless)
Button { step(by: -1) } label: { Image(systemName: "backward.frame.fill") }
.keyboardShortcut(.leftArrow, modifiers: [])
.help("Ein Frame zurück (←)")
.buttonStyle(.borderless)
Button { step(by: 1) } label: { Image(systemName: "forward.frame.fill") }
.keyboardShortcut(.rightArrow, modifiers: [])
.help("Ein Frame vor (→)")
.buttonStyle(.borderless)
Text(timecode(currentTime))
.monospacedDigit()
.font(.caption)
TimelineBar(
duration: duration,
currentTime: currentTime,
keyframes: plan.keyframes,
activeIndex: plan.keyframeIndex(at: currentTime),
hoveredID: $hoveredKeyframeID,
onScrub: { seconds, isFinal in seek(to: seconds, precise: isFinal) },
onDelete: deleteKeyframe
)
Text(timecode(duration))
.monospacedDigit()
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var markerBar: some View {
let activeIndex = plan.keyframeIndex(at: currentTime)
let active = plan.keyframes[activeIndex]
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button(action: addMarker) {
Label("Marke setzen", systemImage: "plus.diamond")
}
.keyboardShortcut("m", modifiers: [])
.help("Ab hier gilt eine eigene Rahmenposition (M)")
Button(action: centerFrame) {
Label("Mittig setzen", systemImage: "align.vertical.center")
}
.keyboardShortcut("z", modifiers: [])
.disabled(plan.offset(at: currentTime) == 0)
.help(plan.keyframes.count > 1
? "Rahmen in diesem Abschnitt wieder mittig (Z oder Doppelklick ins Bild)"
: "Rahmen wieder mittig (Z oder Doppelklick ins Bild)")
// Unsichtbar, nur für die Taste: löscht die Marke unter dem
// Mauszeiger, sonst die am Abspielkopf.
Button("Marke löschen", action: deleteMarkerFromKeyboard)
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
Spacer()
if plan.keyframes.count > 1 {
Text("Position \(activeIndex + 1) von \(plan.keyframes.count) · ab \(timecode(active.time))")
.font(.system(size: 11.5))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
Text("Rahmen im Bild nach oben oder unten ziehen, Doppelklick setzt ihn wieder mittig. Bei einem Szenenwechsel an die Stelle spulen und eine Marke setzen – ab dort gilt eine eigene Position. Zum Löschen mit der Maus auf eine Marke zeigen und auf × klicken (oder ⌫).")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Setzt den Rahmen des gerade geltenden Abschnitts zurück in die Mitte.
private func centerFrame() {
plan.setOffset(0, at: currentTime)
}
private func deleteKeyframe(_ id: CropPlan.Keyframe.ID) {
plan.removeKeyframe(id: id)
if hoveredKeyframeID == id { hoveredKeyframeID = nil }
}
private func deleteMarkerFromKeyboard() {
if let hoveredKeyframeID {
deleteKeyframe(hoveredKeyframeID)
return
}
let frameTime = displayedFrameTime(currentTime)
if let atPlayhead = plan.keyframes.dropFirst().first(where: { abs($0.time - frameTime) <= CropPlan.timeTolerance }) {
deleteKeyframe(atPlayhead.id)
}
}
// MARK: - Player
private func load() async {
do {
let info = try await VideoInspector.inspect(sourceURL)
videoSize = info.pixelSize
player = AVPlayer(url: sourceURL)
let inspected = try await VideoInspector.inspect(sourceURL)
let newPlayer = AVPlayer(url: sourceURL)
let interval = CMTime(value: 1, timescale: 30)
timeObserver = newPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
currentTime = CMTimeGetSeconds(time)
isPlaying = newPlayer.rate != 0
}
info = inspected
player = newPlayer
} catch {
loadError = "Konnte Vorschau nicht laden."
}
}
private func tearDown() {
player?.pause()
if let timeObserver {
player?.removeTimeObserver(timeObserver)
}
timeObserver = nil
}
private func togglePlayback() {
guard let player else { return }
if player.rate != 0 {
pause()
return
}
if let info, currentTime >= CMTimeGetSeconds(info.duration) - 0.05 {
seek(to: 0, precise: true)
}
player.play()
isPlaying = true
}
private func pause() {
player?.pause()
isPlaying = false
}
private func seek(to seconds: Double, precise: Bool) {
guard let player else { return }
let time = CMTime(seconds: seconds, preferredTimescale: 600)
let tolerance = precise ? CMTime.zero : CMTime(seconds: 0.1, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance)
currentTime = seconds
}
private func step(by frames: Int) {
guard let info, info.frameRate > 0 else { return }
pause()
let duration = CMTimeGetSeconds(info.duration)
let target = displayedFrameTime(currentTime) + Double(frames) / Double(info.frameRate)
seek(to: min(max(target, 0), duration), precise: true)
}
/// Marke auf den Beginn des angezeigten Frames legen, damit genau dieser
/// Frame schon die neue Position bekommt.
private func addMarker() {
pause()
plan.addKeyframe(at: displayedFrameTime(currentTime))
}
/// Startzeit des Frames, der bei `seconds` zu sehen ist.
private func displayedFrameTime(_ seconds: Double) -> Double {
guard let info, info.frameRate > 0 else { return seconds }
let fps = Double(info.frameRate)
return ((seconds * fps) + CropPlan.timeTolerance).rounded(.down) / fps
}
/// `m:ss:ff` — Minuten, Sekunden, Frame.
private func timecode(_ seconds: Double) -> String {
let fps = max(Int((info?.frameRate ?? 25).rounded()), 1)
let totalFrames = Int((max(seconds, 0) * Double(fps) + CropPlan.timeTolerance).rounded(.down))
let frames = totalFrames % fps
let totalSeconds = totalFrames / fps
return String(format: "%d:%02d:%02d", totalSeconds / 60, totalSeconds % 60, frames)
}
}
/// Zeitleiste mit Abspielkopf und Positionsmarken. Klicken oder Ziehen
/// spult; nahe an einer Marke rastet der Kopf auf ihr ein. Zeigt die Maus auf
/// eine Marke, erscheint darüber ein × zum Löschen; Klick auf die Marke
/// springt zu ihr, Rechtsklick bietet ebenfalls Löschen an.
private struct TimelineBar: View {
let duration: Double
let currentTime: Double
let keyframes: [CropPlan.Keyframe]
let activeIndex: Int
@Binding var hoveredID: CropPlan.Keyframe.ID?
let onScrub: (_ seconds: Double, _ isFinal: Bool) -> Void
let onDelete: (CropPlan.Keyframe.ID) -> Void
private let snapDistance: CGFloat = 6
private let height: CGFloat = 40
/// Höhe der Spur innerhalb der Leiste; darüber ist Platz für das ×.
private let trackY: CGFloat = 28
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .topLeading) {
// Spur und Scrub-Fläche
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in onScrub(time(at: value.location.x, width: width), false) }
.onEnded { value in onScrub(time(at: value.location.x, width: width), true) }
)
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: width, height: 4)
.position(x: width / 2, y: trackY)
.allowsHitTesting(false)
activeSegment(width: width)
Capsule()
.fill(Color.primary)
.frame(width: 2, height: 18)
.position(x: x(for: currentTime, width: width), y: trackY)
.allowsHitTesting(false)
ForEach(Array(keyframes.enumerated()), id: \.element.id) { index, keyframe in
marker(keyframe, index: index)
.position(x: x(for: keyframe.time, width: width), y: height / 2)
}
}
}
.frame(height: height)
}
/// Marke samt Löschknopf darüber — beides eine Hover-Fläche, damit das ×
/// nicht verschwindet, sobald die Maus von der Raute zu ihm wandert.
private func marker(_ keyframe: CropPlan.Keyframe, index: Int) -> some View {
let isHovered = hoveredID == keyframe.id
let isDeletable = index > 0
return VStack(spacing: 0) {
Button { onDelete(keyframe.id) } label: {
Image(systemName: "xmark")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
.frame(width: 14, height: 14)
.background(Circle().fill(Color.primary))
}
.buttonStyle(.plain)
.opacity(isHovered && isDeletable ? 1 : 0)
.allowsHitTesting(isHovered && isDeletable)
.help("Marke löschen")
.frame(height: 18)
Rectangle()
.fill(index == activeIndex || isHovered ? Color.gold : Color.gold.opacity(0.6))
.frame(width: isHovered ? 11 : 9, height: isHovered ? 11 : 9)
.rotationEffect(.degrees(45))
.frame(width: 22, height: 22)
.contentShape(Rectangle())
.onTapGesture { onScrub(keyframe.time, true) }
}
.frame(width: 22, height: height)
.contentShape(Rectangle())
.onHover { inside in
if inside {
hoveredID = keyframe.id
} else if hoveredID == keyframe.id {
hoveredID = nil
}
}
.contextMenu {
Button("Zu dieser Marke springen") { onScrub(keyframe.time, true) }
if isDeletable {
Button("Marke löschen", role: .destructive) { onDelete(keyframe.id) }
}
}
.animation(.easeOut(duration: 0.12), value: isHovered)
}
private func activeSegment(width: CGFloat) -> some View {
let start = keyframes[activeIndex].time
let end = activeIndex + 1 < keyframes.count ? keyframes[activeIndex + 1].time : duration
let startX = x(for: start, width: width)
let segmentWidth = max(x(for: end, width: width) - startX, 0)
return Capsule()
.fill(Color.gold.opacity(0.55))
.frame(width: segmentWidth, height: 4)
.position(x: startX + segmentWidth / 2, y: trackY)
.allowsHitTesting(false)
}
private func x(for seconds: Double, width: CGFloat) -> CGFloat {
guard duration > 0 else { return 0 }
return CGFloat(min(max(seconds / duration, 0), 1)) * width
}
private func time(at x: CGFloat, width: CGFloat) -> Double {
if let snapped = keyframes.first(where: { abs(self.x(for: $0.time, width: width) - x) <= snapDistance }) {
return snapped.time
}
guard width > 0 else { return 0 }
return Double(min(max(x / width, 0), 1)) * duration
}
}

@ -6,54 +6,43 @@ struct SettingsView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Einstellungen")
.font(.title2.bold())
Form {
folderRow(
title: "Zielordner",
caption: "Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.",
current: settings.targetFolder
) { settings.setTargetFolder($0) }
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) }
folderRow(
title: "Quellordner",
caption: "„Dateien wählen …“ öffnet sich hier. Optional.",
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()
.formStyle(.grouped)
HStack {
Spacer()
Button("Fertig") { dismiss() }
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
}
.padding([.horizontal, .bottom], 20)
}
.padding(20)
.frame(minWidth: 440, minHeight: 280)
.frame(width: 520, height: 300)
}
@ViewBuilder
private func folderRow(current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
HStack {
Text(current?.path ?? "Nicht gesetzt")
private func folderRow(title: LocalizedStringKey, caption: LocalizedStringKey, current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
LabeledContent {
Button("Wählen …") {
if let url = FolderPicker.choose(startingAt: current) { onChoose(url) }
}
} label: {
Text(title)
Text(current?.path ?? String(localized: "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)
}
Text(caption)
}
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.