MyApp/GalleryView.swift
9.9 KB · 188 lines · swift · Download from the repository page
import SwiftUIimport AVFoundation struct GalleryView: View { @Bindable var game: GameSessionStore var libraryOnly = false @Environment(\.dismiss) private var dismiss @State private var replay: Attempt? @State private var preferExport = false @State private var playerID: UUID? @State private var scope: ClipScope = .currentGame @State private var deleteCandidate: Attempt? @State private var confirmDelete = false @State private var deleteAll = false private var clips: [Attempt] { GameQueries.clips(attempts: game.attempts, sessionID: game.sessionID, scope: scope, playerID: playerID) } private var filterPlayers: [Player] { var seen: Set<UUID> = [] return game.attempts.filter { scope == .allSaved || $0.snapshot.sessionID == game.sessionID } .map(\.snapshot.player).filter { seen.insert($0.id).inserted } } var body: some View { NavigationStack { RetroScreen { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroClipScopeControl(selection: $scope).disabled(libraryOnly || !game.sessionStarted) Picker("Player", selection: $playerID) { Text("All players").tag(nil as UUID?) ForEach(filterPlayers) { player in Text("\(player.name) · \(player.id.uuidString.prefix(4))").tag(Optional(player.id)) } } Text("All Saved shows recordings retained by this app, across games.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) } } if clips.isEmpty { RetroEmptyState( title: playerID != nil ? "No matching clips" : scope == .currentGame ? "No current-game clips" : "No saved clips", systemImage: "video", message: "Finalized recordings appear here, including pending and incorrect attempts." ) } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: RetroTheme.spaceM)], spacing: RetroTheme.spaceM) { ForEach(clips) { attempt in ClipListRow(game: game, attempt: attempt, preview: { exported in preferExport = exported; replay = attempt }, delete: { deleteCandidate = attempt; confirmDelete = true }) } } } } .retroTitle("Clip Collection") .task { if libraryOnly || !game.sessionStarted { scope = .allSaved } } .toolbar { if libraryOnly { Button("Done") { dismiss() } } } .onChange(of: scope) { _, _ in playerID = nil } .onChange(of: game.sessionID) { _, _ in scope = .currentGame; playerID = nil } .sheet(item: $replay) { ClipPlayerView(game: game, attempt: $0, preferExport: preferExport).retroAppearance() } .confirmationDialog("Delete this clip and its narration? The score stays saved.", isPresented: $confirmDelete, titleVisibility: .visible) { Button("Delete clip", role: .destructive) { if let attempt = deleteCandidate { game.delete(attempt) } deleteCandidate = nil } } .confirmationDialog("Delete all saved clips and scores?", isPresented: $deleteAll, titleVisibility: .visible) { Button("Delete all local data", role: .destructive) { game.deleteAll() } } .safeAreaInset(edge: .bottom) { RetroBottomBar { if game.isExporting { Label("Preparing clip…", systemImage: "hourglass").font(.footnote.bold()) } else if !game.message.isEmpty { Text(game.message).font(.footnote) } Button("Delete all local data", role: .destructive) { deleteAll = true } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .disabled(game.isExporting) .frame(maxWidth: .infinity) } } } }} struct ClipListRow: View { let game: GameSessionStore let attempt: Attempt let preview: (Bool) -> Void let delete: () -> Void @State private var duration: Double? private var statusColor: Color { if attempt.voided { return RetroTheme.ink.opacity(0.5) } switch attempt.verdict?.rawValue { case "correct": return RetroTheme.green case "wrong": return RetroTheme.pink default: return RetroTheme.blue } } var body: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { HStack(alignment: .top, spacing: RetroTheme.spaceM) { ClipThumbnail(url: attempt.rawFile.flatMap(game.mediaURL)) VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { Text(attempt.snapshot.player.name).font(.headline) if let actionTitle = attempt.snapshot.actionTitle, !actionTitle.isEmpty { Text(attempt.snapshot.resolvedActionSummary) .font(.caption.bold()) .foregroundStyle(RetroTheme.ink.opacity(0.75)) } Text(attempt.snapshot.instruction).lineLimit(3) Text(attempt.snapshot.created, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6)) RetroTag(title: attempt.voided ? "VOIDED" : (attempt.verdict?.rawValue.uppercased() ?? "PENDING"), color: statusColor) Text(duration.map { "\(Int($0.rounded())) sec" } ?? "Duration unavailable").font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6)) Text("Game \(attempt.snapshot.sessionID.uuidString.prefix(6))").font(.caption2.monospaced()).foregroundStyle(RetroTheme.ink.opacity(0.5)) } } VStack(spacing: RetroTheme.spaceXS) { Button("Preview & Share Clip", systemImage: "play.circle") { preview(false) } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)).frame(maxWidth: .infinity) if attempt.exportFile != nil { Button("Preview narrated MP4") { preview(true) } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity) } else if attempt.narrationFile != nil { Button("Make narrated MP4") { Task { await game.export(attempt) } } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity) .disabled(game.isExporting || game.isMediaInUse(attempt.id)) } Button("Delete clip", role: .destructive, action: delete) .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity) .disabled(game.isExporting || game.isMediaInUse(attempt.id)) } } } .task(id: attempt.rawFile) { duration = attempt.duration if duration == nil, let name = attempt.rawFile, let url = game.mediaURL(name) { duration = try? await AVURLAsset(url: url).load(.duration).seconds } } }}struct DiagnosticsView: View { @Bindable var game: GameSessionStore @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section("Status") { LabeledContent("Camera", value: game.cameraName) LabeledContent("Capture ready", value: String(game.cameraReady)) LabeledContent("Challenge", value: game.phase.rawValue) LabeledContent("Accessory enabled", value: String(game.accessoryEnabled)) LabeledContent("Accessory available", value: String(game.accessoryAvailable)) LabeledContent("Accessory presented", value: String(game.accessoryPresented)) LabeledContent("Audio route", value: game.audioRoute) } Section { Text("Manual judgments only. No recognition accuracy or physical dual-display validation is claimed.") .font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) } Section { Button("Show simulated participant view") { game.simulatedParticipant = true } Text("Simulation does not verify hardware camera direction or outer-display presentation.") .font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) } } .scrollContentBackground(.hidden) .background { RetroGridBackground().ignoresSafeArea() } .retroTitle("Diagnostics") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } .sharedBackgroundVisibility(.hidden) } .sheet(isPresented: $game.simulatedParticipant) { ParticipantView(presentation: game.participant, image: game.frame, simulated: true, stop: { game.voidAttempt() }, directionChanged: { _, _ in }) .retroAppearance() } } .retroAppearance() }}