MyApp/ClipPreviewView.swift
8.8 KB · 174 lines · swift · Download from the repository page
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 } } }}