You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

257 lines
9.8 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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

Powered by TurnKey Linux.