import UIKit @preconcurrency import ExpoModulesCore import CoreMotion public class CameraView: ExpoView, EXAppLifecycleListener, EXCameraInterface, CameraEvent, CameraSessionManagerDelegate, CameraPhotoCaptureDelegate, CameraVideoRecordingDelegate { public var sessionQueue = DispatchQueue(label: "captureSessionQueue") // Needed to satisfy EXCameraInterface // swiftlint:disable implicitly_unwrapped_optional public var session: AVCaptureSession! private var sessionManager: CameraSessionManager! private var photoCapture: CameraPhotoCapture! private var videoRecording: CameraVideoRecording! // swiftlint:enable implicitly_unwrapped_optional private var lifecycleManager: EXAppLifecycleService? internal var permissionsManager: EXPermissionsInterface? internal var barcodeScanner: BarcodeScanner? internal lazy var previewLayer = AVCaptureVideoPreviewLayer(session: self.session) internal var physicalOrientation: UIDeviceOrientation = .unknown private var motionManager: CMMotionManager = { let mm = CMMotionManager() mm.accelerometerUpdateInterval = 0.2 mm.gyroUpdateInterval = 0.2 return mm }() private var isSessionPaused = false // MARK: Property Observers var responsiveWhenOrientationLocked = false { didSet { updateResponsiveOrientation() } } var videoQuality: VideoQuality = .video1080p { didSet { sessionQueue.async { if self.mode == .video { self.sessionManager.updateSessionPreset(preset: self.videoQuality.toPreset()) } } } } var isScanningBarcodes = false { didSet { sessionQueue.async { [weak self] in guard let self else { return } barcodeScanner?.setIsEnabled(isScanningBarcodes) } } } internal var videoBitrate: Int? var presetCamera = AVCaptureDevice.Position.back { didSet { sessionQueue.async { self.sessionManager.updateDevice() } } } var flashMode = FlashMode.auto var torchEnabled = false { didSet { sessionManager.enableTorch() } } var autoFocus = AVCaptureDevice.FocusMode.continuousAutoFocus { didSet { sessionManager.setFocusMode() } } var pictureSize = PictureSize.high { didSet { updatePictureSize() } } var mode = CameraMode.picture { didSet { sessionQueue.async { self.sessionManager.setCameraMode() let preset = self.mode == .video ? self.videoQuality.toPreset() : self.pictureSize.toCapturePreset() self.sessionManager.updateSessionPreset(preset: preset) } } } var isMuted = false { didSet { sessionQueue.async { self.sessionManager.updateSessionAudioIsMuted() } } } var active = true { didSet { sessionQueue.async { self.sessionManager.updateCameraIsActive() } } } var selectedLens: String? { didSet { sessionQueue.async { self.sessionManager.updateDevice() } } } var animateShutter = true var mirror = false var zoom: CGFloat = 0 { didSet { sessionManager.updateZoom() } } // MARK: - Events let onCameraReady = EventDispatcher() let onMountError = EventDispatcher() let onPictureSaved = EventDispatcher() let onBarcodeScanned = EventDispatcher() let onResponsiveOrientationChanged = EventDispatcher() let onAvailableLensesChanged = EventDispatcher() internal var deviceOrientation: UIInterfaceOrientation { UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.interfaceOrientation ?? .unknown } required init(appContext: AppContext? = nil) { super.init(appContext: appContext) lifecycleManager = appContext?.legacyModule(implementing: EXAppLifecycleService.self) permissionsManager = appContext?.legacyModule(implementing: EXPermissionsInterface.self) sessionManager = CameraSessionManager(delegate: self) photoCapture = CameraPhotoCapture(delegate: self) videoRecording = CameraVideoRecording(delegate: self) session = sessionManager.session #if !targetEnvironment(simulator) setupPreview() #endif barcodeScanner = createBarcodeScanner() UIDevice.current.beginGeneratingDeviceOrientationNotifications() NotificationCenter.default.addObserver( self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil) lifecycleManager?.register(self) sessionManager.initializeCaptureSessionInput() } private func setupPreview() { previewLayer = AVCaptureVideoPreviewLayer(session: sessionManager.session) previewLayer.videoGravity = .resizeAspectFill previewLayer.needsDisplayOnBoundsChange = true } public func onAppForegrounded() { sessionQueue.async { [weak self] in guard let self else { return } if !session.isRunning && isSessionPaused { isSessionPaused = false session.startRunning() if torchEnabled { sessionManager.enableTorch() } } } } public func onAppBackgrounded() { sessionQueue.async { [weak self] in guard let self else { return } if session.isRunning && !isSessionPaused { isSessionPaused = true session.stopRunning() } } } private func updatePictureSize() { #if !targetEnvironment(simulator) sessionQueue.async { if self.mode == .picture { let preset = self.pictureSize.toCapturePreset() self.sessionManager.updateSessionPreset(preset: preset) } } #endif } func setBarcodeScannerSettings(settings: BarcodeSettings) { sessionQueue.async { self.barcodeScanner?.setSettings([BARCODE_TYPES_KEY: settings.toMetadataObjectType()]) } } func emitAvailableLenses() { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onAvailableLensesChanged([ "lenses": self.sessionManager.getAvailableLenses() ]) } } func getAvailableLenses() -> [String] { return sessionManager.getAvailableLenses() } func updateResponsiveOrientation() { if responsiveWhenOrientationLocked { motionManager.startAccelerometerUpdates(to: OperationQueue()) { [weak self] _, error in if error != nil { return } guard let self, let accelerometerData = self.motionManager.accelerometerData else { return } let deviceOrientation = ExpoCameraUtils.deviceOrientation( for: accelerometerData, default: self.physicalOrientation) if deviceOrientation != self.physicalOrientation { self.physicalOrientation = deviceOrientation self.onResponsiveOrientationChanged(["orientation": ExpoCameraUtils.toOrientationString(orientation: deviceOrientation)]) } } } else { motionManager.stopAccelerometerUpdates() } } func takePictureRef(options: TakePictureOptions) async throws -> PictureRef { guard let photoOutput = sessionManager.currentPhotoOutput else { throw CameraOutputNotReadyException() } return try await photoCapture.takePictureRef(options: options, photoOutput: photoOutput) } func takePicturePromise(options: TakePictureOptions) async throws -> [String: Any] { guard let photoOutput = sessionManager.currentPhotoOutput else { throw CameraOutputNotReadyException() } return try await photoCapture.takePicturePromise(options: options, photoOutput: photoOutput) } func record(options: CameraRecordingOptions, promise: Promise) async { guard let videoFileOutput = sessionManager.currentVideoFileOutput else { promise.reject(CameraOutputNotReadyException()) return } await videoRecording.record(options: options, videoFileOutput: videoFileOutput, promise: promise) } @available(iOS 18.0, *) func toggleRecording() { guard let videoFileOutput = sessionManager.currentVideoFileOutput else { return } videoRecording.toggleRecording(videoFileOutput: videoFileOutput) } public override func layoutSubviews() { super.layoutSubviews() self.backgroundColor = .black previewLayer.frame = self.bounds self.layer.insertSublayer(previewLayer, at: 0) } public override func removeFromSuperview() { super.removeFromSuperview() sessionQueue.async { self.sessionManager.stopSession() } lifecycleManager?.unregisterAppLifecycleListener(self) UIDevice.current.endGeneratingDeviceOrientationNotifications() NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil) } func stopRecording() { videoRecording.stopRecording(videoFileOutput: sessionManager.currentVideoFileOutput) } func resumePreview() { previewLayer.connection?.isEnabled = true } func pausePreview() { previewLayer.connection?.isEnabled = false } @objc func orientationChanged() { changePreviewOrientation() } func changePreviewOrientation() { // We shouldn't access the device orientation anywhere but on the main thread Task { @MainActor in let videoOrientation = ExpoCameraUtils.videoOrientation(for: deviceOrientation) if (previewLayer.connection?.isVideoOrientationSupported) == true { physicalOrientation = ExpoCameraUtils.physicalOrientation(for: deviceOrientation) previewLayer.connection?.videoOrientation = videoOrientation } } } private func createBarcodeScanner() -> BarcodeScanner { let scanner = BarcodeScanner(session: session, sessionQueue: sessionQueue) scanner.setPreviewLayer(layer: previewLayer) scanner.setOnBarcodeScanned { [weak self] body in guard let self else { return } if let body { self.onBarcodeScanned(body) } } return scanner } deinit { photoCapture.cleanup() videoRecording.cleanup() } }