@preconcurrency import AVFoundation import Combine import Foundation #if os(iOS) import UIKit #endif @MainActor final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate { struct Recording { let url: URL let duration: TimeInterval } @Published private(set) var isPrepared = false @Published private(set) var isRecording = false @Published private(set) var elapsed: TimeInterval = 0 @Published private(set) var isUsingFrontCamera = true @Published private(set) var isMicrophoneMuted = false let session = AVCaptureSession() var onFinished: ((Result) -> Void)? var hasRequiredAuthorization: Bool { AVCaptureDevice.authorizationStatus(for: .video) == .authorized && AVCaptureDevice.authorizationStatus(for: .audio) == .authorized } private let movieOutput = AVCaptureMovieFileOutput() private let sessionQueue = DispatchQueue( label: "app.luma.video-note.capture-session", qos: .userInitiated ) private let fileInspector = VideoNoteFileInspector() private var cameraInput: AVCaptureDeviceInput? private var microphoneInput: AVCaptureDeviceInput? private var timer: Timer? private var startedAt: Date? private var discardCurrentRecording = false private var stopRequested = false private var requestedMinimumDuration: TimeInterval = 0 private var sessionGeneration = UUID() private var lifecycle: VideoNoteRecordingLifecycle = .idle private var activeRecordingURL: URL? private var stopRequestTask: Task? private var startupTimeoutTask: Task? private var finalizationTimeoutTask: Task? private var fileValidationTask: Task? private struct CaptureGraph { let cameraInput: AVCaptureDeviceInput let microphoneInput: AVCaptureDeviceInput let usesFrontCamera: Bool } private struct MovieOutputSnapshot: Sendable { let isRecording: Bool let recordedDuration: TimeInterval } func prepare() async throws { try await waitForPreviousRecordingToFinish() if lifecycle == .prepared, isPrepared { return } guard lifecycle == .idle, !isPrepared else { throw VideoNoteRecorderError.recordingBusy } let generation = UUID() sessionGeneration = generation await waitForPendingSessionOperations() guard !Task.isCancelled, sessionGeneration == generation else { throw CancellationError() } guard await Self.requestAccess(for: .video) else { throw VideoNoteRecorderError.cameraPermissionDenied } guard !Task.isCancelled, sessionGeneration == generation else { throw CancellationError() } guard await Self.requestAccess(for: .audio) else { throw VideoNoteRecorderError.microphonePermissionDenied } guard !Task.isCancelled, sessionGeneration == generation else { throw CancellationError() } var didPrepare = false defer { if !didPrepare { performShutdown() } } #if os(iOS) try AVAudioSession.sharedInstance().setCategory( .playAndRecord, mode: .videoRecording, options: [.defaultToSpeaker, .allowBluetoothHFP] ) try AVAudioSession.sharedInstance().setActive(true) #endif let graph = try await configureCaptureGraph() guard !Task.isCancelled, sessionGeneration == generation else { throw CancellationError() } cameraInput = graph.cameraInput microphoneInput = graph.microphoneInput isUsingFrontCamera = graph.usesFrontCamera configureVideoConnection() try await startCaptureSession(generation: generation) isPrepared = true lifecycle = .prepared didPrepare = true } func start() throws { guard lifecycle == .prepared, isPrepared, session.isRunning else { throw VideoNoteRecorderError.notPrepared } guard !movieOutput.isRecording, activeRecordingURL == nil else { throw VideoNoteRecorderError.recordingBusy } let directory = FileManager.default.temporaryDirectory .appendingPathComponent("LumaRecordings", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov") discardCurrentRecording = false stopRequested = false requestedMinimumDuration = 0 activeRecordingURL = url lifecycle = .starting startedAt = nil elapsed = 0 isRecording = true let output = movieOutput let delegate = self sessionQueue.async { output.startRecording(to: url, recordingDelegate: delegate) } scheduleStartupTimeout(for: url) } func stop() { requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration) } func stop(afterMinimumDuration minimumDuration: TimeInterval) async { requestStop(afterMinimumDuration: minimumDuration) } func cancel() { discardCurrentRecording = true stopRequested = true requestedMinimumDuration = 0 switch lifecycle { case .starting, .recording: lifecycle = .stopping stopRequestTask?.cancel() stopRequestTask = Task { @MainActor [weak self] in guard let self else { return } await self.stopMovieOutput() guard !Task.isCancelled, self.lifecycle == .stopping, let url = self.activeRecordingURL else { return } self.scheduleFinalizationTimeout(for: url) } case .stopping: break case .idle, .prepared: performShutdown() } } func switchCamera() async throws { #if os(iOS) guard isPrepared, lifecycle == .prepared, let currentInput = cameraInput else { throw VideoNoteRecorderError.notPrepared } let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front guard let camera = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: nextPosition ) else { throw VideoNoteRecorderError.alternateCameraUnavailable } let nextInput = try AVCaptureDeviceInput(device: camera) let session = session try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in sessionQueue.async { session.beginConfiguration() session.removeInput(currentInput) guard session.canAddInput(nextInput) else { if session.canAddInput(currentInput) { session.addInput(currentInput) } session.commitConfiguration() continuation.resume(throwing: VideoNoteRecorderError.configurationFailed) return } session.addInput(nextInput) session.commitConfiguration() continuation.resume() } } cameraInput = nextInput isUsingFrontCamera = nextPosition == .front configureVideoConnection() #else throw VideoNoteRecorderError.alternateCameraUnavailable #endif } func toggleMicrophoneMuted() { guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return } isMicrophoneMuted.toggle() connection.isEnabled = !isMicrophoneMuted } func shutdown() { if lifecycle.isBusy || movieOutput.isRecording { cancel() return } performShutdown() } nonisolated func fileOutput( _ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection] ) { Task { @MainActor [weak self] in self?.recordingDidStart(url: fileURL) } } nonisolated func fileOutput( _ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error? ) { Task { @MainActor [weak self] in self?.recordingDidFinish(url: outputFileURL, error: error) } } private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) { guard isRecording, lifecycle == .starting || lifecycle == .recording else { return } stopRequested = true requestedMinimumDuration = max( requestedMinimumDuration, max(0, minimumDuration) ) scheduleStopAfterActualStartIfNeeded() } private func recordingDidStart(url: URL) { guard VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: activeRecordingURL, outputURL: url ), isRecording, lifecycle == .starting || lifecycle == .stopping else { return } startupTimeoutTask?.cancel() startupTimeoutTask = nil if startedAt == nil { startedAt = Date() } if lifecycle == .stopping { // Cancellation can be requested while startRecording is still // crossing the capture queue. If the first stop observed // isRecording == false, stop again after the real didStart rather // than leaving an orphaned capture running until the watchdog. stopRequestTask?.cancel() stopRequestTask = Task { @MainActor [weak self] in guard let self else { return } await self.stopMovieOutput() guard !Task.isCancelled, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.scheduleFinalizationTimeout(for: url) } return } lifecycle = .recording timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in Task { @MainActor in guard let self, let startedAt = self.startedAt else { return } self.elapsed = min( VideoNoteStopPolicy.maximumCaptureDuration, Date().timeIntervalSince(startedAt) ) } } scheduleStopAfterActualStartIfNeeded() } private func scheduleStopAfterActualStartIfNeeded() { guard stopRequested, lifecycle == .recording, startedAt != nil, let url = activeRecordingURL else { return } stopRequestTask?.cancel() let minimumDuration = requestedMinimumDuration let mediaDeadline = Date().addingTimeInterval( VideoNoteStopPolicy.recordedDurationWaitTimeout ) stopRequestTask = Task { @MainActor [weak self] in while true { guard !Task.isCancelled, let self, self.lifecycle == .recording, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } let snapshot = await self.movieOutputSnapshot() guard !Task.isCancelled, self.lifecycle == .recording, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } if !snapshot.isRecording { // AVFoundation stopped without the user's stop reaching // the output. Wait for didFinish, but never let this state // remain without a finalization watchdog. self.lifecycle = .stopping self.scheduleFinalizationTimeout(for: url) return } let remaining = VideoNoteStopPolicy.remainingRecordedDuration( recordedDuration: snapshot.recordedDuration, minimumDuration: minimumDuration ) if remaining <= 0 || Date() >= mediaDeadline { break } do { try await Task.sleep( nanoseconds: min( VideoNoteStopPolicy.recordedDurationPollNanoseconds, UInt64((remaining * 1_000_000_000).rounded(.up)) ) ) } catch { return } } guard !Task.isCancelled, let self, self.lifecycle == .recording, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.lifecycle = .stopping await self.stopMovieOutput() guard !Task.isCancelled, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.scheduleFinalizationTimeout(for: url) } } private func recordingDidFinish(url: URL, error: Error?) { guard VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: activeRecordingURL, outputURL: url ) else { // A delayed callback from an earlier attempt must not tear down a // newer capture graph. Do not delete it here: macOS can spell the // same temporary URL through a filesystem alias, and preserving a // doubtful callback is safer than deleting the active movie. return } stopRequestTask?.cancel() stopRequestTask = nil startupTimeoutTask?.cancel() startupTimeoutTask = nil finalizationTimeoutTask?.cancel() finalizationTimeoutTask = nil timer?.invalidate() timer = nil isRecording = false lifecycle = .stopping let fallbackDuration = measuredWallClockDuration let shouldDiscard = discardCurrentRecording let wasStopRequested = stopRequested if shouldDiscard { try? FileManager.default.removeItem(at: url) finishAndReset(nil) return } fileValidationTask?.cancel() fileValidationTask = Task { @MainActor [weak self] in guard let self else { return } let inspection = await self.fileInspector.inspect( url: url, fallbackDuration: fallbackDuration ) guard !Task.isCancelled, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.fileValidationTask = nil if self.discardCurrentRecording { try? FileManager.default.removeItem(at: url) self.finishAndReset(nil) return } if let inspection, VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( stopRequested: wasStopRequested, error: error, recordedDuration: inspection.duration, maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration ), VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) { self.finishAndReset( .success( Recording( url: url, duration: inspection.duration ))) return } try? FileManager.default.removeItem(at: url) let failure: Error if !wasStopRequested, !VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( stopRequested: false, error: error, recordedDuration: inspection?.duration ?? fallbackDuration, maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration ) { failure = VideoNoteRecorderError.recordingInterrupted } else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error { failure = error } else { failure = VideoNoteRecorderError.emptyRecording } self.finishAndReset(.failure(failure)) } } private var measuredWallClockDuration: TimeInterval { guard let startedAt else { return max(0, elapsed) } return max(elapsed, Date().timeIntervalSince(startedAt)) } private func stopMovieOutput() async { let output = movieOutput await withCheckedContinuation { (continuation: CheckedContinuation) in sessionQueue.async { if output.isRecording { output.stopRecording() } continuation.resume() } } } private func movieOutputSnapshot() async -> MovieOutputSnapshot { let output = movieOutput return await withCheckedContinuation { continuation in sessionQueue.async { let seconds = CMTimeGetSeconds(output.recordedDuration) continuation.resume( returning: MovieOutputSnapshot( isRecording: output.isRecording, recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0 )) } } } private func flushCaptureSessionForFinalization() async { let output = movieOutput let session = session await withCheckedContinuation { (continuation: CheckedContinuation) in sessionQueue.async { if output.isRecording { output.stopRecording() } if session.isRunning { session.stopRunning() } continuation.resume() } } } private func scheduleStartupTimeout(for url: URL) { startupTimeoutTask?.cancel() startupTimeoutTask = Task { @MainActor [weak self] in try? await Task.sleep( nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds ) guard !Task.isCancelled, let self, self.lifecycle == .starting, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.lifecycle = .stopping await self.stopMovieOutput() guard !Task.isCancelled, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.scheduleFinalizationTimeout(for: url) } } private func scheduleFinalizationTimeout(for url: URL) { finalizationTimeoutTask?.cancel() finalizationTimeoutTask = Task { @MainActor [weak self] in try? await Task.sleep( nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds ) guard !Task.isCancelled, let self, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } // A few macOS camera drivers stop the file output but delay its // delegate callback until the capture session itself is flushed. // This is recovery-only; the normal path still waits for // didFinishRecording before touching the session. await self.flushCaptureSessionForFinalization() try? await Task.sleep( nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds ) guard !Task.isCancelled, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } let fallbackDuration = self.measuredWallClockDuration let inspection = await self.fileInspector.inspect( url: url, fallbackDuration: fallbackDuration ) guard !Task.isCancelled, self.lifecycle == .stopping, VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url ) else { return } self.finalizationTimeoutTask = nil if self.discardCurrentRecording { try? FileManager.default.removeItem(at: url) self.finishAndReset(nil) } else if let inspection, VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( stopRequested: self.stopRequested, error: nil, recordedDuration: inspection.duration, maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration ), VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) { // Some macOS camera drivers finalize the .mov but omit the // delegate callback. A playable file is more authoritative // than the missing callback and remains safe to send. self.finishAndReset( .success( Recording( url: url, duration: inspection.duration ))) } else if !self.stopRequested { try? FileManager.default.removeItem(at: url) self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted)) } else { try? FileManager.default.removeItem(at: url) self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut)) } } } private func finishAndReset(_ result: Result?) { fileValidationTask = nil let completion = onFinished performShutdown() if let result { completion?(result) } } private func performShutdown() { sessionGeneration = UUID() stopRequestTask?.cancel() stopRequestTask = nil startupTimeoutTask?.cancel() startupTimeoutTask = nil finalizationTimeoutTask?.cancel() finalizationTimeoutTask = nil fileValidationTask?.cancel() fileValidationTask = nil timer?.invalidate() timer = nil resetCaptureGraph() lifecycle = .idle activeRecordingURL = nil discardCurrentRecording = false stopRequested = false requestedMinimumDuration = 0 startedAt = nil elapsed = 0 isPrepared = false isRecording = false isUsingFrontCamera = true isMicrophoneMuted = false #if os(iOS) try? AVAudioSession.sharedInstance().setActive( false, options: .notifyOthersOnDeactivation) #endif } private func waitForPreviousRecordingToFinish() async throws { guard lifecycle != .starting, lifecycle != .recording else { throw VideoNoteRecorderError.recordingBusy } let deadline = Date().addingTimeInterval(10) while lifecycle == .stopping, Date() < deadline { try await Task.sleep(nanoseconds: 25_000_000) } guard lifecycle != .stopping else { throw VideoNoteRecorderError.finalizationTimedOut } } private func configureVideoConnection() { guard let connection = movieOutput.connection(with: .video) else { return } #if os(iOS) configureInteroperableVideoCodec(for: connection) #endif if connection.isVideoMirroringSupported { connection.automaticallyAdjustsVideoMirroring = false connection.isVideoMirrored = isUsingFrontCamera } #if os(iOS) if #available(iOS 17.0, *) { let angle = videoRotationAngle if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle } } else { if connection.isVideoOrientationSupported { connection.videoOrientation = Self.currentVideoOrientation } } #endif } #if os(iOS) private var videoRotationAngle: CGFloat { switch UIDevice.current.orientation { case .portraitUpsideDown: return 180 case .landscapeLeft: return 90 case .landscapeRight: return 270 default: return 0 } } @available(iOS, deprecated: 17.0) private static var currentVideoOrientation: AVCaptureVideoOrientation { switch UIDevice.current.orientation { case .portraitUpsideDown: return .portraitUpsideDown case .landscapeLeft: return .landscapeRight case .landscapeRight: return .landscapeLeft default: return .portrait } } #endif #if os(iOS) private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) { guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return } movieOutput.setOutputSettings( [AVVideoCodecKey: AVVideoCodecType.h264], for: connection ) } #endif private func resetCaptureGraph() { let session = session sessionQueue.async { if session.isRunning { session.stopRunning() } guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return } session.beginConfiguration() session.inputs.forEach { session.removeInput($0) } session.outputs.forEach { session.removeOutput($0) } session.commitConfiguration() } cameraInput = nil microphoneInput = nil } private func waitForPendingSessionOperations() async { await withCheckedContinuation { (continuation: CheckedContinuation) in sessionQueue.async { continuation.resume() } } } private func configureCaptureGraph() async throws -> CaptureGraph { let session = session let movieOutput = movieOutput return try await withCheckedThrowingContinuation { continuation in sessionQueue.async { session.beginConfiguration() do { session.sessionPreset = session.canSetSessionPreset(.vga640x480) ? .vga640x480 : .medium #if os(iOS) let camera = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: .front ) ?? AVCaptureDevice.default(for: .video) #else let camera = AVCaptureDevice.default(for: .video) #endif guard let camera else { throw VideoNoteRecorderError.cameraUnavailable } let cameraInput = try AVCaptureDeviceInput(device: camera) guard session.canAddInput(cameraInput) else { throw VideoNoteRecorderError.configurationFailed } session.addInput(cameraInput) guard let microphone = AVCaptureDevice.default(for: .audio) else { throw VideoNoteRecorderError.microphoneUnavailable } let microphoneInput = try AVCaptureDeviceInput(device: microphone) guard session.canAddInput(microphoneInput) else { throw VideoNoteRecorderError.configurationFailed } session.addInput(microphoneInput) guard session.canAddOutput(movieOutput) else { throw VideoNoteRecorderError.configurationFailed } session.addOutput(movieOutput) movieOutput.maxRecordedDuration = CMTime( seconds: VideoNoteStopPolicy.maximumCaptureDuration, preferredTimescale: 600 ) session.commitConfiguration() continuation.resume( returning: CaptureGraph( cameraInput: cameraInput, microphoneInput: microphoneInput, usesFrontCamera: camera.position == .front )) } catch { session.commitConfiguration() continuation.resume(throwing: error) } } } } private func startCaptureSession(generation: UUID) async throws { let session = session await withCheckedContinuation { (continuation: CheckedContinuation) in sessionQueue.async { if !session.isRunning { session.startRunning() } continuation.resume() } } guard !Task.isCancelled, sessionGeneration == generation, session.isRunning else { throw CancellationError() } } private static func requestAccess(for mediaType: AVMediaType) async -> Bool { switch AVCaptureDevice.authorizationStatus(for: mediaType) { case .authorized: return true case .denied, .restricted: return false case .notDetermined: return await AVCaptureDevice.requestAccess(for: mediaType) @unknown default: return false } } } private actor VideoNoteFileInspector { struct Inspection: Sendable { let duration: TimeInterval } func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? { let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000] for delay in retryDelays { if delay > 0 { try? await Task.sleep(nanoseconds: delay) } guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), values.isRegularFile == true, let size = values.fileSize, size > 1_024 else { continue } let asset = AVURLAsset(url: url) guard let duration = try? await asset.load(.duration), duration.seconds.isFinite, duration.seconds > 0.04, let tracks = try? await asset.loadTracks(withMediaType: .video), !tracks.isEmpty else { continue } return Inspection(duration: duration.seconds) } // Keep a non-empty file only when AVFoundation reported enough wall // clock time to make a useful fallback. This branch covers a small set // of macOS camera drivers that delay movie metadata longer than the // delegate callback itself. guard fallbackDuration > 0.25, let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let size = values.fileSize, size > 16_384 else { return nil } return Inspection(duration: fallbackDuration) } } enum VideoNoteStopPolicy { static let minimumCaptureDuration: TimeInterval = 1.2 static let minimumValidFileDuration: TimeInterval = 0.8 static let maximumCaptureDuration: TimeInterval = 60 static let recordedDurationWaitTimeout: TimeInterval = 5 static let recordedDurationPollNanoseconds: UInt64 = 80_000_000 static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000 static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000 static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000 static func remainingRecordedDuration( recordedDuration: TimeInterval, minimumDuration: TimeInterval ) -> TimeInterval { max(0, max(0, minimumDuration) - max(0, recordedDuration)) } static func isValidFinalDuration(_ duration: TimeInterval) -> Bool { duration.isFinite && duration >= minimumValidFileDuration } } // #if os(iOS) // extension VideoNoteRecorder { // fileprivate static var currentVideoOrientation: AVCaptureVideoOrientation { // switch UIDevice.current.orientation { // case .portraitUpsideDown: // return .portraitUpsideDown // case .landscapeLeft: // return .landscapeRight // case .landscapeRight: // return .landscapeLeft // default: // return .portrait // } // } // } // #endif private enum VideoNoteRecorderError: LocalizedError { case cameraPermissionDenied case microphonePermissionDenied case cameraUnavailable case alternateCameraUnavailable case microphoneUnavailable case configurationFailed case notPrepared case recordingBusy case recordingInterrupted case finalizationTimedOut case emptyRecording var errorDescription: String? { switch self { case .cameraPermissionDenied: return "Разрешите Luma доступ к камере в системных настройках." case .microphonePermissionDenied: return "Разрешите Luma доступ к микрофону в системных настройках." case .cameraUnavailable: return "Камера недоступна." case .alternateCameraUnavailable: return "Вторая камера недоступна." case .microphoneUnavailable: return "Микрофон недоступен." case .configurationFailed: return "Не удалось настроить запись видеосообщения." case .notPrepared: return "Камера ещё не готова." case .recordingBusy: return "Предыдущая запись ещё завершается. Подождите секунду и повторите." case .recordingInterrupted: return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись." case .finalizationTimedOut: return "Камера не смогла завершить видеофайл. Повторите запись." case .emptyRecording: return "Камера создала пустой или нечитаемый видеофайл. Повторите запись." } } }