Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/ClipPreviewView.swift

MyApp/ClipPreviewView.swift

8.8 KB · 174 lines · swift · Download from the repository page

MyApp/ClipPreviewView.swift
import SwiftUIimport AVKit enum ClipValidation {    static func validate(_ url: URL) async throws -> Double {        guard FileManager.default.fileExists(atPath: url.path) else {            throw PartyError.message("This clip is missing or expired. Its score is still saved.")        }        let asset = AVURLAsset(url: url)        let playable = try await asset.load(.isPlayable)        let duration = try await asset.load(.duration).seconds        let video = try await asset.loadTracks(withMediaType: .video)        let audio = try await asset.loadTracks(withMediaType: .audio)        guard playable, duration.isFinite, duration > 0, !video.isEmpty, !audio.isEmpty else {            throw PartyError.message("This file is not a playable video with audio. Try its original recording.")        }        return duration    }} struct ClipPlayerView: View {    let game: GameSessionStore    let attempt: Attempt    var preferExport = false    @Environment(\.dismiss) private var dismiss    @State private var player: AVPlayer?    @State private var shareURL: URL?    @State private var isSharing = false    @State private var isLoading = true    @State private var error = ""    @State private var leased = false    @State private var reload = UUID()     var body: some View {        NavigationStack {            ScrollView {                VStack(spacing: RetroTheme.spaceM) {                    Group {                        if let player {                            VideoPlayer(player: player).frame(minHeight: 240)                                .clipShape(RoundedRectangle(cornerRadius: RetroTheme.radius))                                .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border))                        } else if isLoading {                            ZStack { RetroTheme.ink.opacity(0.06); ProgressView("Preparing clip…") }                                .frame(minHeight: 240)                                .clipShape(RoundedRectangle(cornerRadius: RetroTheme.radius))                                .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border))                        } else {                            VStack(spacing: RetroTheme.spaceM) {                                RetroEmptyState(title: "Clip unavailable", systemImage: "video.slash", message: error)                                Button("Retry") { reload = UUID() }.buttonStyle(RetroButtonStyle(color: RetroTheme.surface))                            }                        }                    }                    RetroPanel {                        VStack(alignment: .leading, spacing: RetroTheme.spaceS) {                            if let actionTitle = attempt.snapshot.actionTitle, !actionTitle.isEmpty {                                Text(attempt.snapshot.resolvedActionSummary)                                    .font(.subheadline.bold())                                    .foregroundStyle(RetroTheme.ink.opacity(0.75))                            }                            Text(attempt.snapshot.instruction).font(.headline)                            if let cue = attempt.snapshot.visualCue {                                Text("Visual S/Z · Cue: \(cue.letter)").font(RetroTheme.mono(.subheadline))                            }                            if let delivery = attempt.snapshot.delivery {                                Text(attempt.snapshot.visualCue == nil ? "\(delivery.speed.title) · \(delivery.resolvedPlacement.title) placement" : delivery.speed.title)                                Text("Voice: \(delivery.language)").font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6))                            } else {                                Text("Speech speed and placement: Unknown (legacy clip)").font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6))                            }                            RetroTag(title: attempt.voided ? "VOIDED" : (attempt.verdict?.rawValue.uppercased() ?? "PENDING"))                        }                    }                    Button("Share Clip", systemImage: "square.and.arrow.up") {                        guard shareURL != nil else { return }                        player?.pause()                        game.acquireMedia(attempt.id)                        isSharing = true                    }                    .buttonStyle(RetroButtonStyle()).controlSize(.large)                    .disabled(shareURL == nil || isLoading)                    .frame(maxWidth: .infinity)                    Text("Sharing does not change the result or advance the game.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.6))                }                .padding(.horizontal, RetroTheme.screenPaddingH)                .padding(.vertical, RetroTheme.screenPaddingTop)                .frame(maxWidth: RetroTheme.contentMaxWidth)                .frame(maxWidth: .infinity)            }            .background { RetroGridBackground().ignoresSafeArea() }            .retroTitle("Replay")            .toolbar { Button("Done") { dismiss() } }            .sheet(isPresented: $isSharing, onDismiss: { game.releaseMedia(attempt.id) }) {                if let shareURL { ClipShareSheet(url: shareURL) }            }            .task(id: reload) {                if !leased { game.acquireMedia(attempt.id); leased = true }                isLoading = true; shareURL = nil; error = ""                defer { isLoading = false }                do {                    guard let name = preferExport ? (attempt.exportFile ?? attempt.rawFile) : attempt.rawFile,                          let originalURL = game.mediaURL(name) else { throw PartyError.message("No saved recording is available.") }                    let url = attempt.snapshot.visualCue != nil ? try await game.visualPlaybackURL(attempt) : originalURL                    _ = try await ClipValidation.validate(url)                    try Task.checkCancellation()                    shareURL = url                    player = AVPlayer(url: url)                    player?.play()                } catch { if !Task.isCancelled { self.error = error.localizedDescription } }            }            .onDisappear {                player?.pause()                if leased { game.releaseMedia(attempt.id); leased = false }            }        }        .retroAppearance()    }} struct ClipShareSheet: UIViewControllerRepresentable {    let url: URL    func makeUIViewController(context: Context) -> UIActivityViewController {        UIActivityViewController(activityItems: [url], applicationActivities: nil)    }    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}} @MainActorfinal class ClipThumbnailCache {    static let shared = ClipThumbnailCache()    private let images = NSCache<NSURL, UIImage>()    private var tasks: [URL: Task<UIImage, Error>] = [:]    private init() { images.countLimit = 60 }    func image(for url: URL) async throws -> UIImage {        if let image = images.object(forKey: url as NSURL) { return image }        if let task = tasks[url] { return try await task.value }        let task = Task {            let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))            generator.appliesPreferredTrackTransform = true            generator.maximumSize = CGSize(width: 240, height: 240)            let result = try await generator.image(at: .zero)            return UIImage(cgImage: result.image)        }        tasks[url] = task        defer { tasks[url] = nil }        let image = try await task.value        images.setObject(image, forKey: url as NSURL)        return image    }    func remove(_ url: URL) { images.removeObject(forKey: url as NSURL); tasks[url]?.cancel(); tasks[url] = nil }} struct ClipThumbnail: View {    let url: URL?    @State private var image: UIImage?    @State private var unavailable = false    var body: some View {        ZStack {            RoundedRectangle(cornerRadius: RetroTheme.radius).fill(RetroTheme.ink.opacity(0.08))            if let image { Image(uiImage: image).resizable().scaledToFit() }            else { Image(systemName: unavailable ? "video.slash" : "video").foregroundStyle(RetroTheme.ink.opacity(0.4)) }        }        .frame(width: 96, height: 96).clipShape(.rect(cornerRadius: RetroTheme.radius))        .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border))        .accessibilityLabel(unavailable ? "Preview unavailable" : "Video thumbnail")        .task(id: url) {            guard let url else { unavailable = true; return }            do { image = try await ClipThumbnailCache.shared.image(for: url) }            catch { unavailable = true }        }    }}