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 { 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) } } } } #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 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 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 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: 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): 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").foregroundStyle(.secondary).help(reason.errorDescription ?? "") case .skippedByUser(let reason): Text("übersprungen").foregroundStyle(.secondary).help(reason) case .rejected(let reason): Text(reason.errorDescription ?? "abgelehnt").foregroundStyle(Color.attention).help(reason.errorDescription ?? "") case .failed(let message): Text(message).foregroundStyle(Color.attention).help(message) } } @ViewBuilder 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)) } }