/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import AVFoundation import SwiftUI import Defaults class WebcamManager: NSObject, ObservableObject { static let shared = WebcamManager() @Published var previewLayer: AVCaptureVideoPreviewLayer? { didSet { objectWillChange.send() } } private var captureSession: AVCaptureSession? @Published var isSessionRunning: Bool = false { didSet { objectWillChange.send() } } @Published var authorizationStatus: AVAuthorizationStatus = .notDetermined { didSet { objectWillChange.send() } } @Published var cameraAvailable: Bool = false { didSet { objectWillChange.send() } } @Published var availableCameras: [AVCaptureDevice] = [] { didSet { objectWillChange.send() } } private let sessionQueue = DispatchQueue(label: "DynamicIsland.WebcamManager.SessionQueue", qos: .userInitiated) private var isCleaningUp: Bool = false // MARK: - Constants enum WebcamError: Error, LocalizedError { case deviceUnavailable case accessDenied case configurationFailed(String) var errorDescription: String? { switch self { case .deviceUnavailable: return "No camera devices available" case .accessDenied: return "Camera access denied" case .configurationFailed(let message): return "Camera configuration failed: \(message)" } } } // MARK: - Properties private override init() { super.init() NotificationCenter.default.addObserver(self, selector: #selector(deviceWasDisconnected), name: .AVCaptureDeviceWasDisconnected, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(deviceWasConnected), name: .AVCaptureDeviceWasConnected, object: nil) checkCameraAvailability() } deinit { NotificationCenter.default.removeObserver(self) if let session = captureSession { if session.isRunning { session.stopRunning() } } captureSession = nil previewLayer = nil } // MARK: - Camera Management /// Checks current authorization status and requests access if needed func checkAndRequestVideoAuthorization() { let status = AVCaptureDevice.authorizationStatus(for: .video) DispatchQueue.main.async { self.authorizationStatus = status } switch status { case .authorized: checkCameraAvailability() // Check availability if authorized case .notDetermined: requestVideoAccess() case .denied, .restricted: NSLog("Camera access denied or restricted") @unknown default: NSLog("Unknown authorization status") } } /// Requests access to the camera private func requestVideoAccess() { AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { self?.authorizationStatus = granted ? .authorized : .denied if granted { self?.checkCameraAvailability() // Check availability if access granted } } } } /// Checks if any camera devices are available and sets up capture session if needed func checkCameraAvailability() { let availableDevices = AVCaptureDevice.DiscoverySession( deviceTypes: [.external, .builtInWideAngleCamera, .deskViewCamera, .externalUnknown], mediaType: .video, position: .unspecified ).devices let hasAvailableDevices = !availableDevices.isEmpty DispatchQueue.main.async { self.availableCameras = availableDevices self.cameraAvailable = hasAvailableDevices } } /// Sets up the capture session with a completion handler private func setupCaptureSession(completion: @escaping (Bool) -> Void) { sessionQueue.async { [weak self] in guard let self = self else { completion(false) return } // Clean up any existing session before creating a new one self.cleanupExistingSession() let session = AVCaptureSession() do { // Get available devices let discoverySession = AVCaptureDevice.DiscoverySession( deviceTypes: [.external, .builtInWideAngleCamera, .deskViewCamera, .externalUnknown], mediaType: .video, position: .unspecified ) let devices = discoverySession.devices let selectedID = Defaults[.selectedCameraID] let videoDevice: AVCaptureDevice? if !selectedID.isEmpty { videoDevice = devices.first(where: { $0.uniqueID == selectedID }) ?? devices.first } else { videoDevice = devices.first } guard let videoDevice = videoDevice else { NSLog("No video devices available") DispatchQueue.main.async { self.isSessionRunning = false self.cameraAvailable = false } completion(false) return } NSLog("Using camera: \(videoDevice.localizedName) (ID: \(videoDevice.uniqueID))") // Lock device for configuration try videoDevice.lockForConfiguration() defer { videoDevice.unlockForConfiguration() } let videoInput = try AVCaptureDeviceInput(device: videoDevice) guard session.canAddInput(videoInput) else { throw NSError(domain: "DynamicIsland.WebcamManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"]) } session.beginConfiguration() session.sessionPreset = .high session.addInput(videoInput) let videoOutput = AVCaptureVideoDataOutput() videoOutput.setSampleBufferDelegate(nil, queue: nil) if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } session.commitConfiguration() self.captureSession = session // Create and set up preview layer on main thread DispatchQueue.main.async { self.cameraAvailable = true let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill self.previewLayer = previewLayer // Setup is complete, let the caller know completion(true) } NSLog("Capture session setup completed successfully") } catch { NSLog("Failed to setup capture session: \(error.localizedDescription)") DispatchQueue.main.async { self.isSessionRunning = false self.cameraAvailable = false self.previewLayer = nil } completion(false) } } } /// Cleans up an existing capture session, removing all inputs and outputs private func cleanupExistingSession() { if let existingSession = self.captureSession { // First stop the session if running if existingSession.isRunning { existingSession.stopRunning() } // Then perform configuration cleanup existingSession.beginConfiguration() // Remove all inputs and outputs for input in existingSession.inputs { existingSession.removeInput(input) } for output in existingSession.outputs { existingSession.removeOutput(output) } existingSession.commitConfiguration() self.captureSession = nil // Clear preview layer on main thread DispatchQueue.main.async { self.previewLayer = nil } } } @objc private func deviceWasDisconnected(notification: Notification) { NSLog("Camera device was disconnected") sessionQueue.async { [weak self] in guard let self = self else { return } self.stopSession() DispatchQueue.main.async { self.cameraAvailable = false } } } @objc private func deviceWasConnected(notification: Notification) { NSLog("Camera device was connected") sessionQueue.async { [weak self] in guard let self = self else { return } self.checkCameraAvailability() } } private func updateSessionState() { let isRunning = self.captureSession?.isRunning ?? false DispatchQueue.main.async { self.isSessionRunning = isRunning } } func startSession() { sessionQueue.async { [weak self] in guard let self = self else { return } // If no session exists, create new session if self.captureSession == nil { self.setupCaptureSession { success in if success { // Only start the session if setup was successful self.startRunningCaptureSession() } } } else { // Session already exists, just start it self.startRunningCaptureSession() } } } private func startRunningCaptureSession() { sessionQueue.async { [weak self] in guard let self = self, let session = self.captureSession, !session.isRunning else { return } session.startRunning() // Update state on main thread self.updateSessionState() NSLog("Capture session started successfully") } } func stopSession() { sessionQueue.async { [weak self] in guard let self = self else { return } // Update state to indicate we're stopping DispatchQueue.main.async { self.isSessionRunning = false } self.cleanupExistingSession() NSLog("Capture session stopped and cleaned up") } } }