damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 472f81b31166d24476f1bfa7ed98019f16af9c19
parent 7744787c51fa615951ff2e369f1a20fc3836d13a
Author: Suhail Saqan <suhail.saqan@gmail.com>
Date:   Tue, 19 Sep 2023 13:33:41 -0700

add CameraModel and CameraService for interacting with the camera

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 12++++++++++++
Adamus/Models/Camera/CameraModel.swift | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/Camera/CameraService+Extensions.swift | 32++++++++++++++++++++++++++++++++
Adamus/Models/Camera/CameraService.swift | 693+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 859 insertions(+), 0 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -380,6 +380,9 @@ 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE60CDC295E1C5E00105A1F /* Wallet.swift */; }; 50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; }; 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; }; + BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; }; + BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; }; + BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; }; 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */; }; 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A62A34915F006AE6DC /* RelayModel.swift */; }; 504323A92A3495B6006AE6DC /* RelayModelCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504323A82A3495B6006AE6DC /* RelayModelCache.swift */; }; @@ -934,6 +937,9 @@ 4C9B0DED2A65A75F00CBDA21 /* AttrStringTestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttrStringTestExtensions.swift; sourceTree = "<group>"; }; 4C9B0DF22A65C46800CBDA21 /* ProfileEditButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditButton.swift; sourceTree = "<group>"; }; 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayName.swift; sourceTree = "<group>"; }; + BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraService+Extensions.swift"; sourceTree = "<group>"; }; + BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = "<group>"; }; + BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = "<group>"; }; 4C9BB83329C12D9900FC4E37 /* EventProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventProfileName.swift; sourceTree = "<group>"; }; 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeZapView.swift; sourceTree = "<group>"; }; 4C9F18E329ABDE6D008C55EC /* MaybeAnonPfpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeAnonPfpView.swift; sourceTree = "<group>"; }; @@ -2279,6 +2285,9 @@ BA3759882ABCCDE30018D73B /* Camera */ = { isa = PBXGroup; children = ( + BA3759902ABCCEBA0018D73B /* CameraModel.swift */, + BA3759912ABCCEBA0018D73B /* CameraService.swift */, + BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */, BA3759892ABCCDE30018D73B /* ImageResizer.swift */, BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */, BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */, @@ -2642,6 +2651,7 @@ 4C363A8428233689006E126D /* Parser.swift in Sources */, 3AAA95CA298DF87B00F3D526 /* TranslationService.swift in Sources */, 4CE4F9E328528C5200C00DD9 /* AddRelayView.swift in Sources */, + BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */, 4C363A9A28283854006E126D /* Reply.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, 4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */, @@ -2679,6 +2689,7 @@ 4C198DF529F88D2E004C165C /* ImageMetadata.swift in Sources */, 4CCEB7AE29B53D260078AA28 /* SearchingEventView.swift in Sources */, 4CF0ABE929844AF100D66079 /* AnyCodable.swift in Sources */, + BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */, 4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */, 4C3AC79F2833115300E1F516 /* FollowButtonView.swift in Sources */, 4C4E137B2A76D5FB00BDD832 /* MuteThreadNotify.swift in Sources */, @@ -2788,6 +2799,7 @@ 4C5F9114283D694D0052CD1C /* FollowTarget.swift in Sources */, 4CF0ABD629817F5B00D66079 /* ReportView.swift in Sources */, 4C1A9A2729DDE31900516EAC /* TranslationSettingsView.swift in Sources */, + BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */, 4CB8838629656C8B00DC99E7 /* NIP05.swift in Sources */, 4CF0ABD82981980C00D66079 /* Lists.swift in Sources */, F71694EA2A662232001F4053 /* SuggestedUsersView.swift in Sources */, diff --git a/damus/Models/Camera/CameraModel.swift b/damus/Models/Camera/CameraModel.swift @@ -0,0 +1,122 @@ +// +// CameraModel.swift +// damus +// +// Created by Suhail Saqan on 8/5/23. +// + +import Foundation +import AVFoundation +import Combine + +final class CameraModel: ObservableObject { + private let service = CameraService() + + @Published var showAlertError = false + + @Published var isFlashOn = false + + @Published var willCapturePhoto = false + + @Published var isCameraButtonDisabled = false + + @Published var isPhotoProcessing = false + + @Published var isRecording = false + + @Published var captureMode: CameraMediaType = .image + + @Published public var mediaItems: [MediaItem] = [] + + @Published var thumbnail: Thumbnail! + + var alertError: AlertError! + + var session: AVCaptureSession + + private var subscriptions = Set<AnyCancellable>() + + init() { + self.session = service.session + + service.$shouldShowAlertView.sink { [weak self] (val) in + self?.alertError = self?.service.alertError + self?.showAlertError = val + } + .store(in: &self.subscriptions) + + service.$flashMode.sink { [weak self] (mode) in + self?.isFlashOn = mode == .on + } + .store(in: &self.subscriptions) + + service.$willCapturePhoto.sink { [weak self] (val) in + self?.willCapturePhoto = val + } + .store(in: &self.subscriptions) + + service.$isCameraButtonDisabled.sink { [weak self] (val) in + self?.isCameraButtonDisabled = val + } + .store(in: &self.subscriptions) + + service.$isPhotoProcessing.sink { [weak self] (val) in + self?.isPhotoProcessing = val + } + .store(in: &self.subscriptions) + + service.$isRecording.sink { [weak self] (val) in + self?.isRecording = val + } + .store(in: &self.subscriptions) + + service.$captureMode.sink { [weak self] (mode) in + self?.captureMode = mode + } + .store(in: &self.subscriptions) + + service.$mediaItems.sink { [weak self] (mode) in + self?.mediaItems = mode + } + .store(in: &self.subscriptions) + + service.$thumbnail.sink { [weak self] (thumbnail) in + guard let pic = thumbnail else { return } + self?.thumbnail = pic + } + .store(in: &self.subscriptions) + } + + func configure() { + service.checkForPermissions() + service.configure() + } + + func stop() { + service.stop() + } + + func capturePhoto() { + service.capturePhoto() + } + + func startRecording() { + service.startRecording() + } + + func stopRecording() { + service.stopRecording() + } + + func flipCamera() { + service.changeCamera() + } + + func zoom(with factor: CGFloat) { + service.set(zoom: factor) + } + + func switchFlash() { + service.flashMode = service.flashMode == .on ? .off : .on + } +} diff --git a/damus/Models/Camera/CameraService+Extensions.swift b/damus/Models/Camera/CameraService+Extensions.swift @@ -0,0 +1,32 @@ +// +// CameraService+Extensions.swift +// damus +// +// Created by Suhail Saqan on 8/5/23. +// + +import Foundation +import UIKit +import AVFoundation + +extension AVCaptureVideoOrientation { + init?(deviceOrientation: UIDeviceOrientation) { + switch deviceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeRight + case .landscapeRight: self = .landscapeLeft + default: return nil + } + } + + init?(interfaceOrientation: UIInterfaceOrientation) { + switch interfaceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeLeft + case .landscapeRight: self = .landscapeRight + default: return nil + } + } +} diff --git a/damus/Models/Camera/CameraService.swift b/damus/Models/Camera/CameraService.swift @@ -0,0 +1,693 @@ +// +// CameraService.swift +// Campus +// +// Created by Suhail Saqan on 8/5/23. +// + +import Foundation +import Combine +import AVFoundation +import Photos +import UIKit + +public struct Thumbnail: Identifiable, Equatable { + public var id: String + public var type: CameraMediaType + public var url: URL + + public init(id: String = UUID().uuidString, type: CameraMediaType, url: URL) { + self.id = id + self.type = type + self.url = url + } + + public var thumbnailImage: UIImage? { + switch type { + case .image: + return ImageResizer(targetWidth: 100).resize(at: url) + case .video: + return generateVideoThumbnail(for: url) + } + } +} + +public struct AlertError { + public var title: String = "" + public var message: String = "" + public var primaryButtonTitle = "Accept" + public var secondaryButtonTitle: String? + public var primaryAction: (() -> ())? + public var secondaryAction: (() -> ())? + + public init(title: String = "", message: String = "", primaryButtonTitle: String = "Accept", secondaryButtonTitle: String? = nil, primaryAction: (() -> ())? = nil, secondaryAction: (() -> ())? = nil) { + self.title = title + self.message = message + self.primaryAction = primaryAction + self.primaryButtonTitle = primaryButtonTitle + self.secondaryAction = secondaryAction + } +} + +func generateVideoThumbnail(for videoURL: URL) -> UIImage? { + let asset = AVAsset(url: videoURL) + let imageGenerator = AVAssetImageGenerator(asset: asset) + imageGenerator.appliesPreferredTrackTransform = true + + do { + let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil) + return UIImage(cgImage: cgImage) + } catch { + print("Error generating thumbnail: \(error)") + return nil + } +} + +public enum CameraMediaType { + case image + case video +} + +public struct MediaItem { + let url: URL + let type: CameraMediaType +} + +public class CameraService: NSObject, Identifiable { + public let session = AVCaptureSession() + + public var isSessionRunning = false + public var isConfigured = false + var setupResult: SessionSetupResult = .success + + public var alertError: AlertError = AlertError() + + @Published public var flashMode: AVCaptureDevice.FlashMode = .off + @Published public var shouldShowAlertView = false + @Published public var isPhotoProcessing = false + @Published public var captureMode: CameraMediaType = .image + @Published public var isRecording: Bool = false + + @Published public var willCapturePhoto = false + @Published public var isCameraButtonDisabled = false + @Published public var isCameraUnavailable = false + @Published public var thumbnail: Thumbnail? + @Published public var mediaItems: [MediaItem] = [] + + public let sessionQueue = DispatchQueue(label: "io.damus.camera") + + @objc dynamic public var videoDeviceInput: AVCaptureDeviceInput! + @objc dynamic public var audioDeviceInput: AVCaptureDeviceInput! + + public let videoDeviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .builtInDualCamera, .builtInTrueDepthCamera], mediaType: .video, position: .unspecified) + + public let photoOutput = AVCapturePhotoOutput() + + public let movieOutput = AVCaptureMovieFileOutput() + + var videoCaptureProcessor: VideoCaptureProcessor? + var photoCaptureProcessor: PhotoCaptureProcessor? + + public var keyValueObservations = [NSKeyValueObservation]() + + override public init() { + super.init() + + DispatchQueue.main.async { + self.isCameraButtonDisabled = true + self.isCameraUnavailable = true + } + } + + enum SessionSetupResult { + case success + case notAuthorized + case configurationFailed + } + + public func configure() { + if !self.isSessionRunning && !self.isConfigured { + sessionQueue.async { + self.configureSession() + } + } + } + + public func checkForPermissions() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + break + case .notDetermined: + sessionQueue.suspend() + AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in + if !granted { + self.setupResult = .notAuthorized + } + self.sessionQueue.resume() + }) + + default: + setupResult = .notAuthorized + + DispatchQueue.main.async { + self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, + options: [:], completionHandler: nil) + + }, secondaryAction: nil) + self.shouldShowAlertView = true + self.isCameraUnavailable = true + self.isCameraButtonDisabled = true + } + } + } + + private func configureSession() { + if setupResult != .success { + return + } + + session.beginConfiguration() + + session.sessionPreset = .high + + // Add video input. + do { + var defaultVideoDevice: AVCaptureDevice? + + if let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { + // If a rear dual camera is not available, default to the rear wide angle camera. + defaultVideoDevice = backCameraDevice + } else if let frontCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) { + // If the rear wide angle camera isn't available, default to the front wide angle camera. + defaultVideoDevice = frontCameraDevice + } + + guard let videoDevice = defaultVideoDevice else { + print("Default video device is unavailable.") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + + let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) + + if session.canAddInput(videoDeviceInput) { + session.addInput(videoDeviceInput) + self.videoDeviceInput = videoDeviceInput + } else { + print("Couldn't add video device input to the session.") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + + let audioDevice = AVCaptureDevice.default(for: .audio) + let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!) + + if session.canAddInput(audioDeviceInput) { + session.addInput(audioDeviceInput) + self.audioDeviceInput = audioDeviceInput + } else { + print("Couldn't add audio device input to the session.") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + + // Add video output + if session.canAddOutput(movieOutput) { + session.addOutput(movieOutput) + } else { + print("Could not add movie output to the session") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + } catch { + print("Couldn't create video device input: \(error)") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + + // Add the photo output. + if session.canAddOutput(photoOutput) { + session.addOutput(photoOutput) + + photoOutput.maxPhotoQualityPrioritization = .quality + + } else { + print("Could not add photo output to the session") + setupResult = .configurationFailed + session.commitConfiguration() + return + } + + session.commitConfiguration() + self.isConfigured = true + + self.start() + } + + private func resumeInterruptedSession() { + sessionQueue.async { + self.session.startRunning() + self.isSessionRunning = self.session.isRunning + if !self.session.isRunning { + DispatchQueue.main.async { + self.alertError = AlertError(title: "Camera Error", message: "Unable to resume camera", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) + self.shouldShowAlertView = true + self.isCameraUnavailable = true + self.isCameraButtonDisabled = true + } + } else { + DispatchQueue.main.async { + self.isCameraUnavailable = false + self.isCameraButtonDisabled = false + } + } + } + } + + public func changeCamera() { + DispatchQueue.main.async { + self.isCameraButtonDisabled = true + } + + sessionQueue.async { + let currentVideoDevice = self.videoDeviceInput.device + let currentPosition = currentVideoDevice.position + + let preferredPosition: AVCaptureDevice.Position + let preferredDeviceType: AVCaptureDevice.DeviceType + + switch currentPosition { + case .unspecified, .front: + preferredPosition = .back + preferredDeviceType = .builtInWideAngleCamera + + case .back: + preferredPosition = .front + preferredDeviceType = .builtInWideAngleCamera + + @unknown default: + print("Unknown capture position. Defaulting to back, dual-camera.") + preferredPosition = .back + preferredDeviceType = .builtInWideAngleCamera + } + let devices = self.videoDeviceDiscoverySession.devices + var newVideoDevice: AVCaptureDevice? = nil + + if let device = devices.first(where: { $0.position == preferredPosition && $0.deviceType == preferredDeviceType }) { + newVideoDevice = device + } else if let device = devices.first(where: { $0.position == preferredPosition }) { + newVideoDevice = device + } + + if let videoDevice = newVideoDevice { + do { + let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) + + self.session.beginConfiguration() + + self.session.removeInput(self.videoDeviceInput) + + if self.session.canAddInput(videoDeviceInput) { + NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentVideoDevice) + NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: videoDeviceInput.device) + + self.session.addInput(videoDeviceInput) + self.videoDeviceInput = videoDeviceInput + } else { + self.session.addInput(self.videoDeviceInput) + } + + if let connection = self.photoOutput.connection(with: .video) { + if connection.isVideoStabilizationSupported { + connection.preferredVideoStabilizationMode = .auto + } + } + + self.photoOutput.maxPhotoQualityPrioritization = .quality + + self.session.commitConfiguration() + } catch { + print("Error occurred while creating video device input: \(error)") + } + } + + DispatchQueue.main.async { + self.isCameraButtonDisabled = false + } + } + } + + public func focus(with focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) { + sessionQueue.async { + guard let device = self.videoDeviceInput?.device else { return } + do { + try device.lockForConfiguration() + + if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) { + device.focusPointOfInterest = devicePoint + device.focusMode = focusMode + } + + if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) { + device.exposurePointOfInterest = devicePoint + device.exposureMode = exposureMode + } + + device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange + device.unlockForConfiguration() + } catch { + print("Could not lock device for configuration: \(error)") + } + } + } + + + public func focus(at focusPoint: CGPoint) { + let device = self.videoDeviceInput.device + do { + try device.lockForConfiguration() + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = focusPoint + device.exposurePointOfInterest = focusPoint + device.exposureMode = .continuousAutoExposure + device.focusMode = .continuousAutoFocus + device.unlockForConfiguration() + } + } + catch { + print(error.localizedDescription) + } + } + + @objc public func stop(completion: (() -> ())? = nil) { + sessionQueue.async { + if self.isSessionRunning { + if self.setupResult == .success { + self.session.stopRunning() + self.isSessionRunning = self.session.isRunning + print("CAMERA STOPPED") + self.removeObservers() + + if !self.session.isRunning { + DispatchQueue.main.async { + self.isCameraButtonDisabled = true + self.isCameraUnavailable = true + completion?() + } + } + } + } + } + } + + @objc public func start() { + sessionQueue.async { + if !self.isSessionRunning && self.isConfigured { + switch self.setupResult { + case .success: + self.addObservers() + self.session.startRunning() + print("CAMERA RUNNING") + self.isSessionRunning = self.session.isRunning + + if self.session.isRunning { + DispatchQueue.main.async { + self.isCameraButtonDisabled = false + self.isCameraUnavailable = false + } + } + + case .notAuthorized: + print("Application not authorized to use camera") + DispatchQueue.main.async { + self.isCameraButtonDisabled = true + self.isCameraUnavailable = true + } + + case .configurationFailed: + DispatchQueue.main.async { + self.alertError = AlertError(title: "Camera Error", message: "Camera configuration failed. Either your device camera is not available or other application is using it", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) + self.shouldShowAlertView = true + self.isCameraButtonDisabled = true + self.isCameraUnavailable = true + } + } + } + } + } + + public func set(zoom: CGFloat) { + let factor = zoom < 1 ? 1 : zoom + let device = self.videoDeviceInput.device + + do { + try device.lockForConfiguration() + device.videoZoomFactor = factor + device.unlockForConfiguration() + } + catch { + print(error.localizedDescription) + } + } + + public func capturePhoto() { + if self.setupResult != .configurationFailed { + let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait + self.isCameraButtonDisabled = true + + sessionQueue.async { + if let photoOutputConnection = self.photoOutput.connection(with: .video) { + photoOutputConnection.videoOrientation = videoPreviewLayerOrientation + } + var photoSettings = AVCapturePhotoSettings() + + // Capture HEIF photos when supported. Enable according to user settings and high-resolution photos. + if (self.photoOutput.availablePhotoCodecTypes.contains(.hevc)) { + photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) + } + + if self.videoDeviceInput.device.isFlashAvailable { + photoSettings.flashMode = self.flashMode + } + + if !photoSettings.__availablePreviewPhotoPixelFormatTypes.isEmpty { + photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoSettings.__availablePreviewPhotoPixelFormatTypes.first!] + } + + photoSettings.photoQualityPrioritization = .speed + + if self.photoCaptureProcessor == nil { + self.photoCaptureProcessor = PhotoCaptureProcessor(with: photoSettings, photoOutput: self.photoOutput, willCapturePhotoAnimation: { + DispatchQueue.main.async { + self.willCapturePhoto.toggle() + self.willCapturePhoto.toggle() + } + }, completionHandler: { (photoCaptureProcessor) in + if let data = photoCaptureProcessor.photoData { + let url = self.savePhoto(data: data) + if let unwrappedURL = url { + self.thumbnail = Thumbnail(type: .image, url: unwrappedURL) + } + } else { + print("Data for photo not found") + } + + self.isCameraButtonDisabled = false + }, photoProcessingHandler: { animate in + self.isPhotoProcessing = animate + }) + } + + self.photoCaptureProcessor?.capturePhoto(settings: photoSettings) + } + } + } + + public func startRecording() { + if self.setupResult != .configurationFailed { + let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait + self.isCameraButtonDisabled = true + + sessionQueue.async { + if let videoOutputConnection = self.movieOutput.connection(with: .video) { + videoOutputConnection.videoOrientation = videoPreviewLayerOrientation + + var videoSettings = [String: Any]() + + if self.movieOutput.availableVideoCodecTypes.contains(.hevc) == true { + videoSettings[AVVideoCodecKey] = AVVideoCodecType.hevc + self.movieOutput.setOutputSettings(videoSettings, for: videoOutputConnection) + } + } + + if self.videoCaptureProcessor == nil { + self.videoCaptureProcessor = VideoCaptureProcessor(movieOutput: self.movieOutput, beginHandler: { + self.isRecording = true + }, completionHandler: { (videoCaptureProcessor, outputFileURL) in + self.isCameraButtonDisabled = false + self.captureMode = .image + + self.mediaItems.append(MediaItem(url: outputFileURL, type: .video)) + self.thumbnail = Thumbnail(type: .video, url: outputFileURL) + }, videoProcessingHandler: { animate in + self.isPhotoProcessing = animate + }) + } + + self.videoCaptureProcessor?.startCapture(session: self.session) + } + } + } + + func stopRecording() { + if let videoCaptureProcessor = self.videoCaptureProcessor { + isRecording = false + videoCaptureProcessor.stopCapture() + } + } + + func savePhoto(imageType: String = "jpeg", data: Data) -> URL? { + guard let uiImage = UIImage(data: data) else { + print("Error converting media data to UIImage") + return nil + } + + guard let compressedData = uiImage.jpegData(compressionQuality: 0.8) else { + print("Error converting UIImage to JPEG data") + return nil + } + + let temporaryDirectory = NSTemporaryDirectory() + let tempFileName = "\(UUID().uuidString).\(imageType)" + let tempFileURL = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent(tempFileName) + + do { + try compressedData.write(to: tempFileURL) + self.mediaItems.append(MediaItem(url: tempFileURL, type: .image)) + return tempFileURL + } catch { + print("Error saving image data to temporary URL: \(error.localizedDescription)") + } + return nil + } + + private func addObservers() { + let systemPressureStateObservation = observe(\.videoDeviceInput.device.systemPressureState, options: .new) { _, change in + guard let systemPressureState = change.newValue else { return } + self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) + } + keyValueObservations.append(systemPressureStateObservation) + +// NotificationCenter.default.addObserver(self, selector: #selector(self.onOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(subjectAreaDidChange), + name: .AVCaptureDeviceSubjectAreaDidChange, + object: videoDeviceInput.device) + + NotificationCenter.default.addObserver(self, selector: #selector(uiRequestedNewFocusArea), name: .init(rawValue: "UserDidRequestNewFocusPoint"), object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(sessionRuntimeError), + name: .AVCaptureSessionRuntimeError, + object: session) + + NotificationCenter.default.addObserver(self, + selector: #selector(sessionWasInterrupted), + name: .AVCaptureSessionWasInterrupted, + object: session) + + NotificationCenter.default.addObserver(self, + selector: #selector(sessionInterruptionEnded), + name: .AVCaptureSessionInterruptionEnded, + object: session) + } + + private func removeObservers() { + NotificationCenter.default.removeObserver(self) + + for keyValueObservation in keyValueObservations { + keyValueObservation.invalidate() + } + keyValueObservations.removeAll() + } + + @objc private func uiRequestedNewFocusArea(notification: NSNotification) { + guard let userInfo = notification.userInfo as? [String: Any], let devicePoint = userInfo["devicePoint"] as? CGPoint else { return } + self.focus(at: devicePoint) + } + + @objc + private func subjectAreaDidChange(notification: NSNotification) { + let devicePoint = CGPoint(x: 0.5, y: 0.5) + focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false) + } + + @objc + private func sessionRuntimeError(notification: NSNotification) { + guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } + + print("Capture session runtime error: \(error)") + + if error.code == .mediaServicesWereReset { + sessionQueue.async { + if self.isSessionRunning { + self.session.startRunning() + self.isSessionRunning = self.session.isRunning + } + } + } + } + + private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) { + let pressureLevel = systemPressureState.level + if pressureLevel == .serious || pressureLevel == .critical { + do { + try self.videoDeviceInput.device.lockForConfiguration() + print("WARNING: Reached elevated system pressure level: \(pressureLevel). Throttling frame rate.") + self.videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) + self.videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) + self.videoDeviceInput.device.unlockForConfiguration() + } catch { + print("Could not lock device for configuration: \(error)") + } + } else if pressureLevel == .shutdown { + print("Session stopped running due to shutdown system pressure level.") + } + } + + @objc + private func sessionWasInterrupted(notification: NSNotification) { + DispatchQueue.main.async { + self.isCameraUnavailable = true + } + + if let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, + let reasonIntegerValue = userInfoValue.integerValue, + let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) { + print("Capture session was interrupted with reason \(reason)") + + if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient { + print("Session stopped running due to video devies in use by another client.") + } else if reason == .videoDeviceNotAvailableWithMultipleForegroundApps { + print("Session stopped running due to video devies is not available with multiple foreground apps.") + } else if reason == .videoDeviceNotAvailableDueToSystemPressure { + print("Session stopped running due to shutdown system pressure level.") + } + } + } + + @objc + private func sessionInterruptionEnded(notification: NSNotification) { + print("Capture session interruption ended") + DispatchQueue.main.async { + self.isCameraUnavailable = false + } + } +}