#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