diff --git a/Sources/DropTwo/Views/FileListView.swift b/Sources/DropTwo/Views/FileListView.swift index 8973d6e..948a85f 100644 --- a/Sources/DropTwo/Views/FileListView.swift +++ b/Sources/DropTwo/Views/FileListView.swift @@ -2,6 +2,7 @@ import SwiftUI struct FileListView: View { @EnvironmentObject private var coordinator: ConversionCoordinator + @State private var previewTarget: PreviewTarget? var body: some View { List(coordinator.items) { item in @@ -15,10 +16,26 @@ struct FileListView: View { .foregroundStyle(item.status.needsAttention ? Color.orange : .secondary) } 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) } + .sheet(item: $previewTarget) { target in + ReviewSheet(sourceURL: target.url) + } + } + + private struct PreviewTarget: Identifiable { + let url: URL + var id: URL { url } } @ViewBuilder diff --git a/Sources/DropTwo/Views/ReviewSheet.swift b/Sources/DropTwo/Views/ReviewSheet.swift new file mode 100644 index 0000000..a336cf8 --- /dev/null +++ b/Sources/DropTwo/Views/ReviewSheet.swift @@ -0,0 +1,91 @@ +import AVKit +import DropTwoCore +import SwiftUI + +/// Schritt 5: 9:16-Master mit eingeblendetem 4:5-Rahmen zur Framing-Kontrolle. +/// Scrubbar über die nativen AVKit-Kontrollen von `VideoPlayer`. 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). +struct ReviewSheet: View { + let sourceURL: URL + @Environment(\.dismiss) private var dismiss + + @State private var player: AVPlayer? + @State private var videoSize: CGSize? + @State private var loadError: String? + + var body: some View { + VStack(spacing: 12) { + HStack { + Text(sourceURL.lastPathComponent) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Schließen") { dismiss() } + } + content + } + .padding(16) + .frame(minWidth: 420, minHeight: 520) + .task { await load() } + } + + @ViewBuilder + private var content: some View { + if let loadError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + Text(loadError) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let player, let videoSize, videoSize.height > 0 { + GeometryReader { proxy in + ZStack { + VideoPlayer(player: player) + cropOverlay(sourceSize: videoSize, viewSize: proxy.size) + } + } + .aspectRatio(videoSize.width / videoSize.height, contentMode: .fit) + } 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. + @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 + + VStack(spacing: 0) { + Rectangle() + .fill(Color.black.opacity(0.55)) + .frame(height: marginHeight) + Rectangle() + .strokeBorder(Color(red: 0.784, green: 0.663, blue: 0.416), lineWidth: 2) + .frame(height: max(viewSize.height - marginHeight * 2, 0)) + Rectangle() + .fill(Color.black.opacity(0.55)) + .frame(height: marginHeight) + } + .allowsHitTesting(false) + } + + private func load() async { + do { + let info = try await VideoInspector.inspect(sourceURL) + videoSize = info.pixelSize + player = AVPlayer(url: sourceURL) + } catch { + loadError = "Konnte Vorschau nicht laden." + } + } +}