Zum Inhalt

apple/PhysarumApp/Sources/Capture/CaptureSheet.swift

#if !os(tvOS)
import SwiftUI
import AVFoundation
import PhysarumEngine

struct CaptureSheet: View {
    let preset: PresetParams
    let presetName: String
    let viewWidth: Double
    let viewHeight: Double
    let cover: Bool
    let format: SimFormat
    let snapshotProvider: LiveSnapshotProvider
    @Binding var isPresented: Bool

    @Environment(ErrorPresenter.self) private var errorPresenter
    @State private var service = CaptureService()
    @State private var mode: CaptureMode = .photo
    @State private var copied = false
    @State private var videoFromScratch = false
    @State private var liveSnapshot: LiveSnapshot?
    @State private var stillLong = CaptureConfig.defaultStill.longEdge
    @State private var videoLong = CaptureConfig.defaultVideo.longEdge
    @State private var duration = 10
    @State private var codec: VideoCodec = .hevc
    @State private var photoFormat: PhotoFormat = .png
    @State private var showExporter = false

    private enum CaptureMode: Hashable {
        case photo, video, embed
        /// VoiceOver value for the mode picker (the cases are not String-raw).
        var displayName: String {
            switch self {
            case .photo: return "Photo"
            case .video: return "Video"
            case .embed: return "Embed"
            }
        }
    }
    private enum SaveState: Equatable { case idle, saving, saved }
    @State private var saveState: SaveState = .idle
    @State private var thumb: Image?

    /// Video vs still derives from the mode (embed renders nothing, so it reads as still — never used there).
    private var isVideo: Bool { mode == .video }

    private var config: CaptureConfig {
        isVideo
            ? CaptureConfig(kind: .video(durationSeconds: duration), longEdge: videoLong, fps: 60, codec: codec)
            : CaptureConfig(kind: .still, longEdge: stillLong, photoFormat: photoFormat)
    }

    var body: some View {
        // Scrolls so the dense form (segmented pickers + rows) can grow within the
        // fixed `.medium` sheet detent at large Dynamic Type instead of clipping.
        ScrollView {
            VStack(spacing: Theme.Space.s4) {
                Picker("", selection: $mode) {
                    Text("Photo").tag(CaptureMode.photo)
                    Text("Video").tag(CaptureMode.video)
                    Text("Embed").tag(CaptureMode.embed)
                }
                .pickerStyle(.segmented)
                .disabled(service.state != .idle)
                .accessibilityLabel("Capture mode")
                .accessibilityHint("Photo, video, or embed")
                .accessibilityValue(mode.displayName)

                if mode == .embed {
                    embedView
                } else {
                    switch service.state {
                    case .idle:
                        form
                    case .running(let p):
                        hud(progress: p)
                    case .done(let out):
                        doneCard(out)
                    case .failed(let msg):
                        failedView(msg)
                    }
                }
            }
            .padding(Theme.Space.s4)
        }
        .frame(minWidth: 280)
        .onAppear { liveSnapshot = snapshotProvider.takeSnapshot() }
        .onChange(of: service.state) { _, new in
            // The inline failedView keeps the in-context Try-again UI; also report once
            // so VoiceOver users (and anyone not looking at the sheet) hear the failure.
            if case .failed(let msg) = new {
                errorPresenter.report(title: "Capture Failed", message: msg, recovery: .none)
            }
        }
    }

    // MARK: - Form state

    @ViewBuilder private var form: some View {
        VStack(spacing: Theme.Space.s3) {
            if isVideo {
                pickerRow("Resolution", $videoLong, CaptureConfig.offeredVideoSizes(idiom: currentDeviceIdiom),
                          a11yLabel: "Resolution", a11yHint: "Output pixel size") { "\($0)" }
                pickerRow("Duration", $duration, CaptureConfig.offeredVideoDurations(idiom: currentDeviceIdiom),
                          a11yLabel: "Duration", a11yHint: "Length of the clip") { "\($0) s" }
                Picker("Start", selection: $videoFromScratch) {
                    Text("From current").tag(false)
                    Text("From start").tag(true)
                }
                .pickerStyle(.segmented)
                .accessibilityLabel("Video start")
                .accessibilityHint("From current frame or from start")
                .accessibilityValue(videoFromScratch ? "From start" : "From current")
                pickerRow("Codec", $codec, VideoCodec.allCases,
                          a11yLabel: "Video codec", a11yHint: "HEVC or H.264") { $0.label }
            } else {
                pickerRow("Resolution", $stillLong, CaptureConfig.offeredStillSizes(idiom: currentDeviceIdiom),
                          a11yLabel: "Resolution", a11yHint: "Output pixel size") { px in
                    px >= 3840 ? "4K · \(px)" : "\(px)"
                }
                pickerRow("Format", $photoFormat, PhotoFormat.allCases,
                          a11yLabel: "Photo format", a11yHint: "PNG or JPEG") { $0.label }
            }
        }
        PhysButton(title: isVideo ? "Render clip" : "Capture", kind: .ember) { start() }
            .accessibilityLabel(isVideo ? "Render clip" : "Capture")
            .accessibilityHint("Start the export")
    }

    private func pickerRow<T: Hashable>(_ title: String, _ sel: Binding<T>, _ values: [T],
                                        a11yLabel: String? = nil, a11yHint: String? = nil,
                                        _ label: @escaping (T) -> String) -> some View {
        HStack {
            Text(title.uppercased())
                .font(Theme.Fonts.eyebrow)
                .tracking(1.2)
                .foregroundStyle(Theme.Colors.fgTertiary)
            Spacer()
            Picker("", selection: sel) {
                ForEach(values, id: \.self) { Text(label($0)).tag($0) }
            }
            .labelsHidden()
            .accessibilityLabel(a11yLabel ?? title)
            .accessibilityHint(a11yHint ?? "")
            .accessibilityValue(label(sel.wrappedValue))
        }
        .padding(.horizontal, Theme.Space.s3)
        .padding(.vertical, Theme.Space.s2)
        .background(Theme.Colors.overlay, in: RoundedRectangle(cornerRadius: Theme.Radius.md))
        .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md).strokeBorder(Theme.Colors.borderSubtle, lineWidth: 1))
    }

    // MARK: - Embed (web)

    private var embedSnippet: String { EmbedSnippet.make(preset) }

    @ViewBuilder private var embedView: some View {
        VStack(spacing: Theme.Space.s3) {
            Text("Paste this on your website — a live, self-contained Physarum player for this exact preset. Visual only (no audio).")
                .font(Theme.Fonts.eyebrow)
                .foregroundStyle(Theme.Colors.fgTertiary)
                .frame(maxWidth: .infinity, alignment: .leading)
            ScrollView {
                Text(embedSnippet)
                    .font(.system(.caption, design: .monospaced))
                    .textSelection(.enabled)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(Theme.Space.s2)
            }
            .frame(maxHeight: 150)
            .background(Theme.Colors.overlay, in: RoundedRectangle(cornerRadius: Theme.Radius.md))
            .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md).strokeBorder(Theme.Colors.borderSubtle, lineWidth: 1))
            HStack(spacing: Theme.Space.s3) {
                PhysButton(title: copied ? "Copied" : "Copy", kind: .ember) { copyEmbed() }
                    .accessibilityLabel("Copy embed code")
                    .accessibilityHint("Copy the HTML snippet")
                    .accessibilityValue(copied ? "Copied" : "Copy")
                ShareLink(item: embedSnippet) { shareLabel }
                    .accessibilityLabel("Share")
            }
            PhysButton(title: "Done", kind: .ghost) { isPresented = false }
                .accessibilityLabel("Done")
        }
    }

    private func copyEmbed() {
        #if os(iOS)
        UIPasteboard.general.string = embedSnippet
        UINotificationFeedbackGenerator().notificationOccurred(.success)
        #elseif os(macOS)
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(embedSnippet, forType: .string)
        #endif
        copied = true
    }

    // MARK: - Running state

    private func hud(progress: Double) -> some View {
        VStack(spacing: Theme.Space.s3) {
            ProgressView(value: progress)
                .progressViewStyle(.linear)
                .tint(Theme.Colors.ember)
            Text("\(Int(progress * 100)) %")
                .font(Theme.Fonts.value)
                .foregroundStyle(Theme.Colors.fgSecondary)
                .monospacedDigit()
            PhysButton(title: "Cancel", kind: .ghost) { service.cancel() }
                .accessibilityLabel("Cancel")
        }
    }

    // MARK: - Done state

    @ViewBuilder private func doneCard(_ out: CaptureOutput) -> some View {
        // Export uses the capture's UTType; for a Live Photo this is the motion clip
        // (the paired still reaches Photos only via "Save to Photos", not Export/Share).
        let exportUTType = exportType(for: out)
        VStack(spacing: Theme.Space.s3) {
            (thumb ?? Image(systemName: "photo"))
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(maxHeight: 180)
                .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
                .overlay(
                    RoundedRectangle(cornerRadius: Theme.Radius.md)
                        .strokeBorder(Theme.Colors.borderSubtle, lineWidth: 1)
                )

            Text(out.primaryURL.lastPathComponent)
                .font(Theme.Fonts.eyebrow)
                .foregroundStyle(Theme.Colors.fgTertiary)
                .lineLimit(1)
                .truncationMode(.middle)

            switch saveState {
            case .saved:
                Label("Saved to Photos", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
                    .font(Theme.Fonts.label)
            default:
                EmptyView()
            }

            PhysButton(title: saveState == .saving ? "Saving…" : (saveState == .saved ? "Saved" : "Save to Photos"), kind: .ember) { save(out) }
                .disabled(saveState == .saving || saveState == .saved)
                .accessibilityLabel("Save to Photos")
                .accessibilityHint("Save the file to Photos")
                .accessibilityValue(saveState == .saved ? "Saved" : "")

            #if os(macOS)
            PhysButton(title: "Export…", kind: .ghost) { showExporter = true }
                .accessibilityLabel("Export")
                .accessibilityHint("Save to a custom location")
            #endif

            HStack(spacing: Theme.Space.s3) {
                switch out {
                case .livePhoto(let still, let video):
                    ShareLink(items: [still, video]) { shareLabel }
                        .accessibilityLabel("Share")
                case .file(let url, _):
                    ShareLink(item: url) { shareLabel }
                        .accessibilityLabel("Share")
                }
                PhysButton(title: "Done", kind: .ghost) { isPresented = false }
                    .accessibilityLabel("Done")
            }
        }
        .task(id: out.primaryURL) { thumb = await makeThumbnail(out) }
        .fileExporter(isPresented: $showExporter,
                      document: ExportedCapture(url: out.primaryURL, type: exportUTType),
                      contentType: exportUTType,
                      defaultFilename: out.primaryURL.deletingPathExtension().lastPathComponent) { _ in }
    }

    private var shareLabel: some View {
        Text("Share".uppercased())
            .font(Theme.Fonts.label)
            .tracking(1.2)
            .padding(.horizontal, Theme.Space.s4)
            .padding(.vertical, Theme.Space.s2)
            .frame(maxWidth: .infinity)
            .foregroundStyle(Theme.Colors.fgSecondary)
            .background(Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
            .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md)
                .strokeBorder(Theme.Colors.border, lineWidth: 1))
    }

    private func save(_ out: CaptureOutput) {
        saveState = .saving
        Task {
            let result: Result<Void, CaptureSaveError>
            switch out {
            case .file(let u, let v): result = await CaptureSaver.saveToPhotos(u, isVideo: v)
            case .livePhoto(let s, let v): result = await CaptureSaver.saveLivePhoto(still: s, video: v)
            }
            switch result {
            case .success:
                saveState = .saved
                #if os(iOS)
                UINotificationFeedbackGenerator().notificationOccurred(.success)
                #endif
            case .failure(let e):
                saveState = .idle   // the alert now carries the message; no stale inline text
                errorPresenter.report(UserFacingError(captureSaveError: e))
            }
        }
    }

    // MARK: - Failed state

    private func failedView(_ msg: String) -> some View {
        VStack(spacing: Theme.Space.s3) {
            Image(systemName: "exclamationmark.triangle")
                .font(.system(size: 28))
                .foregroundStyle(Theme.Colors.ember)
            Text("Capture failed")
                .font(Theme.Fonts.label)
                .foregroundStyle(Theme.Colors.fgPrimary)
            Text(msg)
                .font(Theme.Fonts.eyebrow)
                .foregroundStyle(Theme.Colors.fgTertiary)
                .multilineTextAlignment(.center)
            PhysButton(title: "Try again", kind: .ghost) { service.reset() }
                .accessibilityLabel("Try again")
                .accessibilityHint("Retry the capture")
        }
        .frame(maxWidth: .infinity)
    }

    // MARK: - Capture

    private func start() {
        saveState = .idle
        thumb = nil
        let resolved = resolveCaptureStart(isVideo: isVideo, videoFromScratch: videoFromScratch,
                                           hasSnapshot: liveSnapshot != nil)
        service.capture(preset: preset, presetName: presetName,
                        viewWidth: viewWidth, viewHeight: viewHeight, cover: cover,
                        format: format, config: config,
                        start: resolved, snapshot: liveSnapshot)
    }

    // MARK: - Thumbnail helpers

    private func makeThumbnail(_ out: CaptureOutput) async -> Image? {
        func platformImage(_ url: URL) -> Image? {
            #if os(iOS)
            guard let ui = UIImage(contentsOfFile: url.path) else { return nil }
            return Image(uiImage: ui)
            #else
            guard let ns = NSImage(contentsOf: url) else { return nil }
            return Image(nsImage: ns)
            #endif
        }
        switch out {
        case .file(let url, let isVideo):
            if isVideo { return await videoFrame(url) }
            return platformImage(url)
        case .livePhoto(let still, _):
            return platformImage(still)
        }
    }

    private func videoFrame(_ url: URL) async -> Image? {
        let gen = AVAssetImageGenerator(asset: AVURLAsset(url: url))
        gen.appliesPreferredTrackTransform = true
        do {
            let result = try await gen.image(at: CMTime(value: 0, timescale: 600))
            let cg = result.image
            #if os(iOS)
            return Image(uiImage: UIImage(cgImage: cg))
            #else
            return Image(nsImage: NSImage(cgImage: cg, size: .zero))
            #endif
        } catch { return nil }
    }
}
#endif