MyApp/GameSessionStore.swift
44 KB · 921 lines · swift · Download from the repository page
import SwiftUIimport AVFoundationimport Observation @MainActor @Observablefinal class GameSessionStore { var players = [Player(name: "Alex"), Player(name: "Jordan")] var challengesPerTurn = 5 var responseSeconds = 3 private var updatingSelectionInternally = false var fingers = 3 { didSet { let clamped = min(5, max(1, fingers)) if fingers != clamped { fingers = clamped return } draftChanged() } } var hand: HandSelection = .either { didSet { draftChanged() } } var selectedCategory: ActionCategory? = nil var actionSource: ActionSelectionSource = .catalog var enabledCategories: Set<ActionCategory> = Set(ActionCategory.allCases) { didSet { if enabledCategories != oldValue { reconcileFilteredSelection() draftChanged() } } } var seatedOnly = false { didSet { if seatedOnly != oldValue { reconcileFilteredSelection() draftChanged() } } } var recordingMode = true var consent = false var recordingAcknowledgement: RecordingAssent? var participantAssents: [UUID: RecordingAssent] = [:] var deleteAtEnd = false var phase: ChallengePhase = .setup var prompts: [Prompt] = [] var selectedPromptID = "hands-up" { didSet { guard !updatingSelectionInternally else { return } if !selectedPromptID.isEmpty { if let matched = prompts.first(where: { $0.matchesID(selectedPromptID) }) { if selectedPromptID != matched.id { updatingSelectionInternally = true selectedPromptID = matched.id updatingSelectionInternally = false } if selectedCategory != nil && selectedCategory != matched.actionCategory { selectedCategory = matched.actionCategory } } actionSource = .catalog } draftChanged() } } var customText = "" { didSet { guard !updatingSelectionInternally else { return } let trimmed = customText.trimmingCharacters(in: .whitespacesAndNewlines) actionSource = trimmed.isEmpty ? .catalog : .custom draftChanged() } } var attempts: [Attempt] = [] var current: Attempt? var sessionID = UUID() var playerIndex = 0 var scoredThisTurn = 0 var hostID: UUID? var hostTab: HostTab = .game var speed: SpeechSpeed = .normal { didSet { draftChanged(); persistPreferences() } } var placement: CuePlacement = .beginning { didSet { draftChanged(); persistPreferences() } } var cueMode: CueMode = .spoken { didSet { draftChanged(); persistPreferences() } } var cueDuration: CueDuration = .standard { didSet { draftChanged(); persistPreferences() } } private var displayedCue: VisualCue? private var cueOnset: ContinuousClock.Instant? private var cueAcknowledged = false var visualAvailable: Bool { recordingMode && cameraReady && accessoryEnabled && accessoryAvailable && accessoryPresented } var isTestingVoice = false private var voiceTestTask: Task<Void, Never>? private var voiceTestGeneration = UUID() private var turnProgress: [UUID: Int] = [:] private var completedTurns: Set<UUID> = [] private var preferencesEnabled = false private var restoring = true private var persistenceHealthy = true private let randomIndex: () -> Int private var mediaLeases: [UUID: Int] = [:] var sessionStarted = false var frame: UIImage? var cameraID = "" var cameraName = "Not selected" var cameras: [AVCaptureDevice] = [] var cameraReady = false var isPreparingCamera = false private var cameraGeneration = UUID() private var lastOrientation = UIDeviceOrientation.unknown private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? var accessoryEnabled = false var accessoryAvailable = false var accessoryPresented = false var countdown: Int? var message = "" var audioRoute = "Not configured" var isExporting = false var simulatedParticipant = false private let capture = CameraCaptureService() private let speech = SpeechPromptService() private var repository: LocalRepository? private var challengeTask: Task<Void, Never>? private var safetyTask: Task<Void, Never>? private var observers: [Task<Void, Never>] = [] private var generation = UUID() private var startInstant: ContinuousClock.Instant? private let clock = ContinuousClock() init(persistenceEnabled: Bool = true, storageDirectory: URL? = nil, randomIndex: @escaping () -> Int = { Int.random(in: 0..<3) }) { self.randomIndex = randomIndex preferencesEnabled = persistenceEnabled && storageDirectory == nil do { guard let url = Bundle.main.url(forResource: "Prompts", withExtension: "json") else { throw PartyError.message("Bundled prompt catalog missing.") } prompts = try JSONDecoder().decode([Prompt].self, from: Data(contentsOf: url)) try Prompt.validateCatalog(prompts) if persistenceEnabled { var directory = storageDirectory #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-testing") { preferencesEnabled = false directory = FileManager.default.temporaryDirectory.appendingPathComponent("PartyUITest-" + UUID().uuidString) } #endif let repository = try LocalRepository(directory: directory) self.repository = repository attempts = try repository.load() try repository.cleanup(&attempts) if let saved = try repository.loadGame() { restore(saved) } else if preferencesEnabled { cueMode = CueMode(rawValue: UserDefaults.standard.string(forKey: "party.cueMode") ?? "spoken") ?? .spoken cueDuration = CueDuration(rawValue: UserDefaults.standard.string(forKey: "party.cueDuration") ?? "standard") ?? .standard speed = SpeechSpeed(rawValue: UserDefaults.standard.object(forKey: "party.speed") as? Int ?? 1) ?? .normal placement = CuePlacement(rawValue: UserDefaults.standard.string(forKey: "party.placement") ?? "beginning") ?? .beginning } } } catch { persistenceHealthy = false; message = "Saved data could not be read. Existing files were preserved. " + error.localizedDescription } restoring = false cameras = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified).devices let capture = capture Task { [weak self] in await capture.setFrameHandler { [weak self] image in guard let self, self.cameraReady else { return } self.frame = image } } UIDevice.current.beginGeneratingDeviceOrientationNotifications() observers.append(Task { [weak self] in for await _ in NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) { guard let self else { return } let orientation = UIDevice.current.orientation guard orientation.isPortrait || orientation.isLandscape else { continue } let changed = self.lastOrientation != .unknown && self.lastOrientation != orientation self.lastOrientation = orientation if changed && (self.cameraReady || self.isPreparingCamera) { self.interrupt("Device rotated. Reframe before the next attempt; no penalty.") } } }) observers.append(Task { [weak self] in for await _ in NotificationCenter.default.notifications(named: ProcessInfo.thermalStateDidChangeNotification) { guard let self else { return } if ProcessInfo.processInfo.thermalState == .critical && (self.cameraReady || self.isPreparingCamera) { self.interrupt("Device needs to cool down. Recording stopped without penalty.") } } }) for name in [AVCaptureSession.wasInterruptedNotification, AVCaptureSession.runtimeErrorNotification, AVAudioSession.interruptionNotification, AVAudioSession.routeChangeNotification] { observers.append(Task { [weak self] in for await _ in NotificationCenter.default.notifications(named: name) { guard let self, self.phase.active || self.cameraReady else { continue } self.interrupt("Capture or audio route changed. Reframe and retry; no penalty.") } }) } } var player: Player { players[playerIndex] } var host: Player { players.first { $0.id == hostID } ?? players[PartyTurnScheduler.hostIndex(playerIndex: playerIndex, count: players.count)] } var availableCategories: [ActionCategory] { ActionCategory.allCases.filter { enabledCategories.contains($0) } } var visiblePrompts: [Prompt] { prompts.filter { prompt in enabledCategories.contains(prompt.actionCategory) && (!seatedOnly || prompt.seated) } } func prompts(in category: ActionCategory) -> [Prompt] { guard enabledCategories.contains(category) else { return [] } return prompts .filter { $0.actionCategory == category && (!seatedOnly || $0.seated) } .sorted { lhs, rhs in if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder } return lhs.id < rhs.id } } func availableActionCount(for category: ActionCategory) -> Int { prompts(in: category).count } func prompt(for id: String) -> Prompt? { guard !id.isEmpty else { return nil } return prompts.first { $0.matchesID(id) } } var selectedPrompt: Prompt? { guard !selectedPromptID.isEmpty else { return nil } return visiblePrompts.first { $0.matchesID(selectedPromptID) } } var canEditChallengeSelection: Bool { !phase.active && phase != .awaitingReview } func selectGroup(_ category: ActionCategory?) { guard canEditChallengeSelection else { return } if let category, !enabledCategories.contains(category) { return } selectedCategory = category updatingSelectionInternally = true selectedPromptID = "" updatingSelectionInternally = false actionSource = .catalog draftChanged() } func selectPrompt(_ prompt: Prompt) { selectPrompt(id: prompt.id) } func selectPrompt(id: String) { guard canEditChallengeSelection else { return } guard let matched = visiblePrompts.first(where: { $0.matchesID(id) }) else { return } if selectedPrompt?.id != matched.id && matched.supportsFingerParameters { fingers = matched.defaultFingers hand = matched.defaultHand } selectedCategory = matched.actionCategory updatingSelectionInternally = true selectedPromptID = matched.id updatingSelectionInternally = false actionSource = .catalog draftChanged() } func setFingerCount(_ count: Int) { guard canEditChallengeSelection else { return } fingers = min(5, max(1, count)) } func setHandSelection(_ newHand: HandSelection) { guard canEditChallengeSelection else { return } hand = newHand } func activateCustomAction() { guard canEditChallengeSelection else { return } let trimmed = customText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } actionSource = .custom draftChanged() } func resetActionFilters() { guard canEditChallengeSelection else { return } enabledCategories = Set(ActionCategory.allCases) seatedOnly = false } private func reconcileFilteredSelection() { if let selectedCategory, !enabledCategories.contains(selectedCategory) { self.selectedCategory = nil updatingSelectionInternally = true selectedPromptID = "" updatingSelectionInternally = false return } if !selectedPromptID.isEmpty, !visiblePrompts.contains(where: { $0.matchesID(selectedPromptID) }) { updatingSelectionInternally = true if selectedCategory != nil { selectedPromptID = "" } else { selectedPromptID = visiblePrompts.first?.id ?? "" } updatingSelectionInternally = false } } var customValidation: String? { PromptDelivery.validationMessage(for: customText) } var activeValidationError: String? { actionSource == .custom ? customValidation : nil } var actionText: String { let custom = customText.trimmingCharacters(in: .whitespacesAndNewlines) switch actionSource { case .custom: return custom case .catalog: return selectedPrompt?.sentence(fingers: fingers, hand: hand) ?? "" } } var selectedActionDisplayWording: String { let custom = customText.trimmingCharacters(in: .whitespacesAndNewlines) switch actionSource { case .custom: guard !custom.isEmpty else { return "" } return PromptDelivery.capitalized(PromptDelivery.cleanAction(custom)) + "." case .catalog: return selectedPrompt?.displaySentence(fingers: fingers, hand: hand) ?? "" } } var canSend: Bool { phase == .ready && (cueMode == .spoken || visualAvailable) && !isTestingVoice && !isPreparingCamera && persistenceHealthy && (!recordingMode || (consent && cameraReady)) && !actionText.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).isEmpty && activeValidationError == nil } var participant: ParticipantPresentation { ParticipantPresentation(status: hostTab != .game && !phase.active ? "Waiting for the host" : phase == .completed ? (current?.verdict == .correct ? "Correct!" : "Next time!") : phase == .speaking ? "Listen…" : phase == .responding ? "Your turn" : phase.active ? "Recording" : "Waiting for the host", countdown: countdown, recording: recordingMode && phase.active, cue: displayedCue) } func score(_ player: Player) -> Int { attempts.filter { $0.snapshot.sessionID == sessionID && $0.snapshot.player.id == player.id && !$0.voided && $0.verdict == .correct }.count } func beginSession() { guard !phase.active, (2...12).contains(players.count), players.allSatisfy({ !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return } stopVoiceTest() sessionID = UUID(); playerIndex = 0; scoredThisTurn = 0; current = nil turnProgress = [:]; completedTurns = []; hostID = players.last?.id sessionStarted = true; hostTab = .game; consent = false selectedCategory = nil actionSource = .catalog updatingSelectionInternally = true selectedPromptID = "" updatingSelectionInternally = false phase = .framing saveSession() if !recordingMode { Task { await prepareCamera() } } } var canSelectPlayer: Bool { !phase.active && phase != .awaitingReview && !isPreparingCamera } var canChangeTab: Bool { !phase.active && !isPreparingCamera } var scoreboard: [ScoreboardEntry] { GameQueries.scoreboard(players: players, attempts: attempts, sessionID: sessionID) } func selectPlayer(_ id: UUID) { guard canSelectPlayer, let index = players.firstIndex(where: { $0.id == id }), index != playerIndex else { return } if phase == .completed { accountCompletedAttempt() if scoredThisTurn >= challengesPerTurn { completedTurns.insert(player.id) turnProgress[player.id] = 0 } } current = nil // The host remains the phone holder; selecting a player never changes hostID. if hostID == nil { hostID = host.id } playerIndex = index; scoredThisTurn = turnProgress[id, default: 0] consent = participantAssents[id] != nil if phase == .finished || phase == .completed { phase = cameraReady || !recordingMode ? .ready : .framing } saveSession() } func setRecordingConsent(_ agrees: Bool) { consent = agrees participantAssents[player.id] = agrees ? RecordingAssent(date: Date(), scopeID: recordingAcknowledgement?.scopeID ?? sessionID) : nil saveSession() } func selectHost(_ id: UUID) { guard canSelectPlayer, players.contains(where: { $0.id == id }) else { return } hostID = id; saveSession() } func selectTab(_ tab: HostTab) { guard canChangeTab else { return } stopVoiceTest(); hostTab = tab } func clearCustomSuggestion() { guard canEditChallengeSelection, !customText.isEmpty else { return } updatingSelectionInternally = true customText = "" updatingSelectionInternally = false actionSource = .catalog draftChanged() } private func draftChanged() { guard !restoring, !phase.active else { return } stopVoiceTest() speech.invalidatePreparedAudio() saveSession() } private func persistPreferences() { guard preferencesEnabled, !restoring else { return } UserDefaults.standard.set(cueMode.rawValue, forKey: "party.cueMode") UserDefaults.standard.set(cueDuration.rawValue, forKey: "party.cueDuration") UserDefaults.standard.set(speed.rawValue, forKey: "party.speed") UserDefaults.standard.set(placement.rawValue, forKey: "party.placement") } func makeSnapshot(simonSays: Bool) throws -> ChallengeSnapshot { guard !actionText.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).isEmpty, activeValidationError == nil else { throw PartyError.message(activeValidationError ?? "Choose an action.") } guard let voice = AVSpeechSynthesisVoice(language: "en-US") ?? AVSpeechSynthesisVoice.speechVoices().first else { throw PartyError.message("Install a speech voice in Settings to continue.") } let resolved = placement.resolve(randomIndex: randomIndex) let delivery = DeliverySnapshot(speed: speed, rate: speed.rate, voiceID: voice.identifier, language: voice.language, configuredPlacement: placement, resolvedPlacement: resolved) let isCustom = actionSource == .custom let catalogPrompt = isCustom ? nil : selectedPrompt let resolvedWording = isCustom ? (PromptDelivery.capitalized(PromptDelivery.cleanAction(actionText)) + ".") : (catalogPrompt?.displaySentence(fingers: fingers, hand: hand) ?? (PromptDelivery.capitalized(PromptDelivery.cleanAction(actionText)) + ".")) return ChallengeSnapshot( id: UUID(), sessionID: sessionID, player: player, host: host, promptID: isCustom ? "custom" : (catalogPrompt?.id ?? selectedPromptID), instruction: PromptDelivery.transcript( action: actionText, includesCue: cueMode == .spoken && simonSays, placement: cueMode == .visual ? .beginning : resolved ), simonSays: simonSays, fingers: fingers, responseDuration: Double(responseSeconds), created: Date(), delivery: delivery, visualCue: cueMode == .visual ? VisualCue(id: UUID(), letter: simonSays ? "S" : "Z", duration: cueDuration.seconds) : nil, suggestedPromptID: isCustom ? "custom" : catalogPrompt?.canonicalSuggestedID, categoryID: isCustom ? "custom" : catalogPrompt?.actionCategory.rawValue, catalogVersion: isCustom ? Prompt.currentCatalogVersion : (catalogPrompt?.catalogVersion ?? Prompt.currentCatalogVersion), actionTitle: isCustom ? "Custom action" : catalogPrompt?.title, actionText: resolvedWording, hand: (catalogPrompt?.supportsFingerParameters == true) ? hand : nil, actionSource: actionSource, judgingMode: isCustom ? .manual : (catalogPrompt?.judgingSupport ?? .manual) ) } func testVoice(rate overrideRate: Float? = nil) { guard !phase.active, !isTestingVoice else { return } let token = UUID(); voiceTestGeneration = token isTestingVoice = true let rate = overrideRate ?? speed.rate voiceTestTask = Task { [weak self] in guard let self else { return } let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".caf") defer { try? FileManager.default.removeItem(at: url) if self.voiceTestGeneration == token { self.isTestingVoice = false } } do { let audio = AVAudioSession.sharedInstance() if !self.cameraReady { try audio.setCategory(.playback) try await AudioSessionController.activate(audio) } try Task.checkCancellation() self.speech.rate = rate try await self.speech.prepare("This is the selected speaking speed.", to: url) try Task.checkCancellation() try self.speech.play() let deadline = ContinuousClock.now.advanced(by: .seconds(15)) while self.speech.playbackFinished == nil { try await Task.sleep(for: .milliseconds(30)) guard ContinuousClock.now < deadline else { throw PartyError.message("Voice test timed out. Try again.") } } } catch { if !Task.isCancelled { self.message = error.localizedDescription } } } } func stopVoiceTest() { voiceTestGeneration = UUID() voiceTestTask?.cancel(); voiceTestTask = nil if isTestingVoice { speech.stop() } isTestingVoice = false } private func accountCompletedAttempt() { guard let current, current.verdict != nil, !current.voided else { return } let id = current.snapshot.player.id turnProgress[id, default: 0] += 1 scoredThisTurn = turnProgress[player.id, default: 0] self.current = nil } func saveSession() { guard !restoring, persistenceHealthy, sessionStarted else { return } do { try repository?.saveGame(SavedGame(id: sessionID, players: players, selectedPlayerID: player.id, hostID: host.id, challengesPerTurn: challengesPerTurn, responseSeconds: responseSeconds, recordingMode: recordingMode, deleteAtEnd: deleteAtEnd, currentAttemptID: current?.id, finished: phase == .finished, turnProgress: turnProgress, completedTurns: completedTurns, speed: speed, placement: placement, selectedPromptID: selectedPromptID, customText: customText, fingers: fingers, cueMode: cueMode, cueDuration: cueDuration, recordingAcknowledgement: recordingAcknowledgement, participantAssents: participantAssents, selectedCategory: selectedCategory, hand: hand, actionSource: actionSource, enabledCategories: Array(enabledCategories).sorted { $0.rawValue < $1.rawValue }, seatedOnly: seatedOnly)) } catch { message = "Could not save game: " + error.localizedDescription } } private func restore(_ saved: SavedGame) { recordingAcknowledgement = saved.recordingAcknowledgement participantAssents = saved.participantAssents ?? [:] cueMode = saved.cueMode ?? .spoken; cueDuration = saved.cueDuration ?? .standard guard (2...12).contains(saved.players.count), let index = saved.players.firstIndex(where: { $0.id == saved.selectedPlayerID }) else { return } sessionID = saved.id; players = saved.players; playerIndex = index; hostID = saved.hostID challengesPerTurn = saved.challengesPerTurn; responseSeconds = saved.responseSeconds recordingMode = saved.recordingMode; deleteAtEnd = saved.deleteAtEnd turnProgress = saved.turnProgress; completedTurns = saved.completedTurns scoredThisTurn = turnProgress[player.id, default: 0] speed = saved.speed; placement = saved.placement if let savedCategories = saved.enabledCategories, !savedCategories.isEmpty { enabledCategories = Set(savedCategories) } if let savedSeated = saved.seatedOnly { seatedOnly = savedSeated } selectedCategory = saved.selectedCategory hand = saved.hand ?? .either updatingSelectionInternally = true selectedPromptID = saved.selectedPromptID customText = saved.customText updatingSelectionInternally = false if let savedSource = saved.actionSource { actionSource = savedSource } else { actionSource = saved.customText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .catalog : .custom } fingers = saved.fingers; sessionStarted = true current = attempts.first { $0.id == saved.currentAttemptID && !$0.voided } phase = saved.finished ? .finished : current != nil ? (current?.verdict == nil ? .awaitingReview : .completed) : .framing // Capture consent/permissions are intentionally never restored as technical readiness. } func directionChanged(ids: [String], outer: Bool) { if phase.active { if !ids.contains(cameraID) { interrupt("Camera direction changed. Attempt voided; reframe before retrying.") } } else if !outer && cameraID.isEmpty, let id = ids.first, let camera = cameras.first(where: { $0.uniqueID == id }) { cameraID = id; cameraName = camera.localizedName } } func prepareCamera() async { guard !isPreparingCamera, phase == .framing || phase == .failure || phase == .interrupted else { return } isPreparingCamera = true defer { isPreparingCamera = false } let token = UUID() cameraGeneration = token if !recordingMode { do { let audio = AVAudioSession.sharedInstance() try audio.setCategory(.playback, mode: .default) try await AudioSessionController.activate(audio) guard cameraGeneration == token else { return } phase = .ready } catch { phase = .failure; message = error.localizedDescription } return } guard consent, !cameraID.isEmpty else { message = "Confirm consent and choose the camera facing the player."; return } phase = .framing do { guard await AVCaptureDevice.requestAccess(for: .video), await AVCaptureDevice.requestAccess(for: .audio) else { throw PartyError.message("Camera and microphone access are required for recording. Enable them in Settings or choose nonrecording mode.") } let audio = AVAudioSession.sharedInstance() try audio.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try await AudioSessionController.activate(audio) try audio.overrideOutputAudioPort(.speaker) audioRoute = audio.currentRoute.outputs.map(\.portName).joined(separator: ", ") guard cameraGeneration == token else { return } guard let camera = cameras.first(where: { $0.uniqueID == cameraID }) else { throw PartyError.message("Selected camera is unavailable.") } let rotation = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: nil) rotationCoordinator = rotation try await capture.configure(cameraID: cameraID, rotationAngle: rotation.videoRotationAngleForHorizonLevelCapture) guard cameraGeneration == token else { await capture.stop(); return } cameraName = cameras.first(where: { $0.uniqueID == cameraID })?.localizedName ?? "Selected camera" cameraReady = true; accessoryEnabled = true; phase = .ready message = "Check that the preview shows the player. Listen to the whole instruction." } catch { await capture.stop() cameraReady = false; accessoryEnabled = false phase = .failure; message = error.localizedDescription } } func send(simonSays: Bool) { guard canSend, let repository else { return } let snapshot: ChallengeSnapshot do { snapshot = try makeSnapshot(simonSays: simonSays) } catch { message = error.localizedDescription; return } speech.rate = snapshot.delivery?.rate ?? AVSpeechUtteranceDefaultSpeechRate speech.voiceID = snapshot.delivery?.voiceID displayedCue = nil; cueOnset = nil; cueAcknowledged = false current = Attempt(snapshot: snapshot) phase = .preparing; message = "" let token = UUID(); generation = token UIApplication.shared.isIdleTimerDisabled = true safetyTask = Task { [weak self] in try? await Task.sleep(for: .seconds(20)) guard !Task.isCancelled, let self, self.generation == token, self.phase.active else { return } self.interrupt("Safety deadline reached. Retry without penalty.") } challengeTask = Task { [weak self] in guard let self else { return } do { let narrationName = snapshot.id.uuidString + ".caf" self.current?.narrationFile = narrationName try await self.speech.prepare(snapshot.instruction, to: repository.url(narrationName)) try Task.checkCancellation() guard self.generation == token else { return } self.current?.narrationFile = narrationName guard self.speech.duration + snapshot.responseDuration + 2.25 < 19 else { throw PartyError.message("This prompt is too long for a bounded recording. Shorten it or reduce the response window.") } if self.recordingMode { let values = try repository.directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) guard (values.volumeAvailableCapacityForImportantUsage ?? 0) > 150_000_000 else { throw PartyError.message("Free at least 150 MB to record.") } let name = snapshot.id.uuidString + ".mov" self.current?.rawFile = name try await self.capture.start(url: repository.url(name)) self.phase = .recordingLeadIn try await self.waitForMedia(0.75) } else { self.startInstant = self.clock.now } try Task.checkCancellation() self.phase = .speaking self.current?.speechStart = self.recordingMode ? try await self.capture.timeline() : 0 try self.speech.play() while self.speech.playbackFinished == nil { try await Task.sleep(for: .milliseconds(30)) try Task.checkCancellation() } guard self.speech.playbackFinished == true else { throw PartyError.message(self.speech.playbackError ?? "Instruction was interrupted. No penalty.") } let speechEnd = self.recordingMode ? try await self.capture.timeline() : self.speech.duration self.current?.speechEnd = speechEnd if let cue = snapshot.visualCue { guard self.visualAvailable else { throw PartyError.message("Outer display unavailable. Visual attempt voided.") } self.displayedCue = cue let presentationDeadline = self.clock.now.advanced(by: .seconds(0.5)) while !self.cueAcknowledged { try Task.checkCancellation() guard self.visualAvailable, self.clock.now < presentationDeadline else { throw PartyError.message("Visual cue was not presented. No penalty.") } try await Task.sleep(for: .milliseconds(10)) } } self.phase = .responding let deadline = (self.cueOnset ?? self.clock.now).advanced(by: .seconds(snapshot.responseDuration)) while self.clock.now < deadline { if let cue = self.displayedCue, let onset = self.cueOnset, self.clock.now >= onset.advanced(by: .seconds(cue.duration)) { self.displayedCue = nil } if snapshot.visualCue != nil && !self.visualAvailable { throw PartyError.message("Outer display disappeared. No penalty.") } let remaining = self.clock.now.duration(to: deadline) self.countdown = max(1, Int(ceil(Double(remaining.components.seconds) + Double(remaining.components.attoseconds) / 1e18))) try await Task.sleep(for: .milliseconds(100)) } self.countdown = nil; self.phase = .evaluating if self.recordingMode { self.phase = .recordingTail let end = try await self.capture.timeline() try await self.waitForMedia(end + 1.5) self.current?.duration = try await self.capture.timeline() _ = try await self.capture.finish() } try Task.checkCancellation() self.phase = .awaitingReview self.safetyTask?.cancel() UIApplication.shared.isIdleTimerDisabled = false self.persistCurrent() } catch { guard self.generation == token else { return } self.interrupt(error.localizedDescription) } } } func cueAppeared(_ id: UUID) { guard let cue = displayedCue, cue.id == id, !cueAcknowledged, phase == .speaking, visualAvailable else { return } cueOnset = clock.now let token = generation Task { do { let time = try await capture.timeline() guard generation == token, displayedCue?.id == id, visualAvailable else { return } current?.cueTiming = CueTiming(start: time, duration: cue.duration) cueAcknowledged = true } catch { interrupt("Cue timing unavailable. No penalty.") } } } func cueDisappeared(_ id: UUID) { if displayedCue?.id == id && phase.active { interrupt("Visual cue disappeared before delivery finished. No penalty.") } else if current?.snapshot.visualCue?.id == id, let timing = current?.cueTiming, phase.active { let token = generation Task { do { let end = try await capture.timeline() guard generation == token, current?.snapshot.visualCue?.id == id else { return } current?.cueTiming = CueTiming(start: timing.start, duration: max(0, end - timing.start)) } catch { interrupt("Cue timing unavailable. No penalty.") } } } } private func waitForMedia(_ target: Double) async throws { let deadline = clock.now.advanced(by: .seconds(4)) while try await capture.timeline() < target { try Task.checkCancellation() guard clock.now < deadline else { throw PartyError.message("Camera or microphone samples stopped arriving.") } try await Task.sleep(for: .milliseconds(20)) } } func judge(_ verdict: Verdict) { guard let current, current.snapshot.visualCue == nil || current.cueTiming != nil, phase == .awaitingReview || phase == .completed else { return } self.current?.history.append(VerdictChange(verdict: verdict, date: Date())) phase = .completed; persistCurrent() } func next() { guard phase == .completed else { return } let departing = player accountCompletedAttempt() if scoredThisTurn >= challengesPerTurn { turnProgress[departing.id] = 0; completedTurns.insert(departing.id); scoredThisTurn = 0 cameraReady = false; accessoryEnabled = false; consent = false; frame = nil phase = .framing let token = UUID(); cameraGeneration = token Task { await capture.stop() guard cameraGeneration == token else { return } if !recordingMode && phase == .framing { await prepareCamera() } } if completedTurns.count == players.count { phase = .finished if deleteAtEnd { deleteSession() } } else { hostID = departing.id playerIndex = (playerIndex + 1) % players.count scoredThisTurn = turnProgress[player.id, default: 0] message = "Pass the phone to \(host.name). \(player.name) is playing next." } } else { phase = cameraReady || !recordingMode ? .ready : .framing } saveSession() } func voidAttempt() { guard current.map({ !isMediaInUse($0.id) }) ?? true else { return } interrupt("Attempt voided. No points or challenge consumed.", voidReviewedAttempt: true) } func interrupt(_ reason: String, voidReviewedAttempt: Bool = false) { if reason.contains("Consent withdrawn") { participantAssents[player.id] = nil consent = false } displayedCue = nil; cueOnset = nil; cueAcknowledged = false let previousPhase = phase let invalidateAttempt = phase.active || voidReviewedAttempt generation = UUID(); cameraGeneration = UUID() challengeTask?.cancel(); safetyTask?.cancel(); speech.stop() countdown = nil; accessoryEnabled = false; cameraReady = false UIApplication.shared.isIdleTimerDisabled = false if var attempt = current, invalidateAttempt { attempt.voided = true // Incomplete media are removed, rather than exposed as a successfully saved clip. let abandoned = attempt Task { await capture.stop() try? repository?.deleteFiles(abandoned) } attempt.rawFile = nil; attempt.narrationFile = nil; attempt.exportFile = nil current = attempt; persistCurrent() } else { Task { await capture.stop() } } frame = nil phase = !invalidateAttempt && (previousPhase == .awaitingReview || previousPhase == .completed) ? previousPhase : .interrupted message = reason saveSession() } func persistCurrent() { guard let current else { return } if let index = attempts.firstIndex(where: { $0.id == current.id }) { attempts[index] = current } else { attempts.append(current) } save(); saveSession() } private func save() { guard persistenceHealthy else { return } do { try repository?.save(attempts) } catch { message = "Could not save metadata: " + error.localizedDescription } } func acquireMedia(_ id: UUID) { mediaLeases[id, default: 0] += 1 } func releaseMedia(_ id: UUID) { if mediaLeases[id, default: 0] <= 1 { mediaLeases[id] = nil } else { mediaLeases[id, default: 0] -= 1 } } func isMediaInUse(_ id: UUID) -> Bool { mediaLeases.keys.contains(id) } func mediaURL(_ name: String) -> URL? { repository?.url(name) } func delete(_ attempt: Attempt) { guard !mediaLeases.keys.contains(attempt.id), !isExporting else { message = "Close the preview or share sheet before deleting this clip."; return } do { for name in [attempt.rawFile, attempt.exportFile].compactMap({ $0 }) { if let url = mediaURL(name) { ClipThumbnailCache.shared.remove(url) } } try repository?.deleteFiles(attempt) // Retain score events while removing the associated media. if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index].rawFile = nil; attempts[index].narrationFile = nil; attempts[index].exportFile = nil if current?.id == attempt.id { current = attempts[index] } } save() } catch { message = error.localizedDescription } } func deleteSession() { for attempt in attempts where attempt.snapshot.sessionID == sessionID { delete(attempt) } } func deleteAll() { guard !phase.active, mediaLeases.isEmpty, !isExporting else { return } do { for attempt in attempts { try repository?.deleteFiles(attempt) } generation = UUID(); cameraGeneration = UUID() challengeTask?.cancel(); safetyTask?.cancel(); speech.stop() cameraReady = false; accessoryEnabled = false; consent = false; frame = nil Task { await capture.stop() } current = nil; countdown = nil attempts.removeAll(); save() try repository?.removeGame(); sessionStarted = false players = [Player(name: "Alex"), Player(name: "Jordan")] playerIndex = 0; scoredThisTurn = 0; sessionID = UUID(); phase = .setup } catch { message = error.localizedDescription } } func visualPlaybackURL(_ attempt: Attempt) async throws -> URL { guard let repository, let raw = attempt.rawFile, let cue = attempt.snapshot.visualCue, let timing = attempt.cueTiming else { throw PartyError.message("Visual cue metadata is unavailable.") } let name = attempt.id.uuidString + "-visual.mp4" let url = repository.url(name) if FileManager.default.fileExists(atPath: url.path), (try? await ClipValidation.validate(url)) != nil { return url } try? FileManager.default.removeItem(at: url) do { try await VisualCueExport.export(raw: repository.url(raw), cue: cue, timing: timing, to: url) _ = try await ClipValidation.validate(url) if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index].exportFile = name } if current?.id == attempt.id { current?.exportFile = name } save() return url } catch { try? FileManager.default.removeItem(at: url) throw error } } func export(_ attempt: Attempt) async { if attempt.snapshot.visualCue != nil { guard !isExporting, !isMediaInUse(attempt.id) else { return } isExporting = true acquireMedia(attempt.id) defer { isExporting = false; releaseMedia(attempt.id) } do { _ = try await visualPlaybackURL(attempt) } catch { message = "Export failed; retry from preview. " + error.localizedDescription } return } guard !isExporting, !mediaLeases.keys.contains(attempt.id), let repository, let raw = attempt.rawFile, let voice = attempt.narrationFile, let offset = attempt.speechStart else { return } isExporting = true defer { isExporting = false } let name = attempt.id.uuidString + "-narrated.mp4" do { let url = repository.url(name) if FileManager.default.fileExists(atPath: url.path), (try? await ClipValidation.validate(url)) == nil { try FileManager.default.removeItem(at: url) } if !FileManager.default.fileExists(atPath: url.path) { try await ClipExportService.export(raw: repository.url(raw), narration: repository.url(voice), offset: offset, to: url) } _ = try await ClipValidation.validate(url) if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index].exportFile = name } if current?.id == attempt.id { current?.exportFile = name } save() } catch { try? FileManager.default.removeItem(at: repository.url(name)) message = "Export failed; original preserved. " + error.localizedDescription } }}