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