diff --git a/Sources/DropTwo/DropTwoApp.swift b/Sources/DropTwo/DropTwoApp.swift index 156fe9c..90182dd 100644 --- a/Sources/DropTwo/DropTwoApp.swift +++ b/Sources/DropTwo/DropTwoApp.swift @@ -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) + } } } } diff --git a/Sources/DropTwo/Model/ConversionCoordinator.swift b/Sources/DropTwo/Model/ConversionCoordinator.swift index 3054722..5e57c4e 100644 --- a/Sources/DropTwo/Model/ConversionCoordinator.swift +++ b/Sources/DropTwo/Model/ConversionCoordinator.swift @@ -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) { 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 diff --git a/Sources/DropTwo/Model/DropItem.swift b/Sources/DropTwo/Model/DropItem.swift index 4d39736..7a2fd49 100644 --- a/Sources/DropTwo/Model/DropItem.swift +++ b/Sources/DropTwo/Model/DropItem.swift @@ -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 + } } diff --git a/Sources/DropTwo/Support/FileImport.swift b/Sources/DropTwo/Support/FileImport.swift new file mode 100644 index 0000000..c6cd9a2 --- /dev/null +++ b/Sources/DropTwo/Support/FileImport.swift @@ -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) } + } +} diff --git a/Sources/DropTwo/Support/FolderPicker.swift b/Sources/DropTwo/Support/FolderPicker.swift new file mode 100644 index 0000000..196f632 --- /dev/null +++ b/Sources/DropTwo/Support/FolderPicker.swift @@ -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 + } +} diff --git a/Sources/DropTwo/Support/Style.swift b/Sources/DropTwo/Support/Style.swift new file mode 100644 index 0000000..79e2987 --- /dev/null +++ b/Sources/DropTwo/Support/Style.swift @@ -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()) + } +} diff --git a/Sources/DropTwo/Support/Updater.swift b/Sources/DropTwo/Support/Updater.swift new file mode 100644 index 0000000..a4c9e85 --- /dev/null +++ b/Sources/DropTwo/Support/Updater.swift @@ -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) + } +} diff --git a/Sources/DropTwo/Views/ContentView.swift b/Sources/DropTwo/Views/ContentView.swift index 7952d70..004d414 100644 --- a/Sources/DropTwo/Views/ContentView.swift +++ b/Sources/DropTwo/Views/ContentView.swift @@ -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?" diff --git a/Sources/DropTwo/Views/DropZoneView.swift b/Sources/DropTwo/Views/DropZoneView.swift deleted file mode 100644 index 2f7e122..0000000 --- a/Sources/DropTwo/Views/DropZoneView.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/DropTwo/Views/FileListView.swift b/Sources/DropTwo/Views/FileListView.swift index 948a85f..411ed4e 100644 --- a/Sources/DropTwo/Views/FileListView.swift +++ b/Sources/DropTwo/Views/FileListView.swift @@ -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)) } } diff --git a/Sources/DropTwo/Views/PlayerView.swift b/Sources/DropTwo/Views/PlayerView.swift index 8306560..41c10be 100644 --- a/Sources/DropTwo/Views/PlayerView.swift +++ b/Sources/DropTwo/Views/PlayerView.swift @@ -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 } diff --git a/Sources/DropTwo/Views/ReviewSheet.swift b/Sources/DropTwo/Views/ReviewSheet.swift index b86add6..9fc9788 100644 --- a/Sources/DropTwo/Views/ReviewSheet.swift +++ b/Sources/DropTwo/Views/ReviewSheet.swift @@ -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 + } } diff --git a/Sources/DropTwo/Views/SettingsView.swift b/Sources/DropTwo/Views/SettingsView.swift index 905200d..d2b48d2 100644 --- a/Sources/DropTwo/Views/SettingsView.swift +++ b/Sources/DropTwo/Views/SettingsView.swift @@ -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) } } }