diff --git a/Sources/DualCameraKit/DualCameraCameraStreamSource.swift b/Sources/DualCameraKit/DualCameraCameraStreamSource.swift index b1b7cfc..aa53a6f 100644 --- a/Sources/DualCameraKit/DualCameraCameraStreamSource.swift +++ b/Sources/DualCameraKit/DualCameraCameraStreamSource.swift @@ -7,6 +7,7 @@ public protocol DualCameraCameraStreamSourcing { nonisolated func stopSession() nonisolated var frontCameraStream: AsyncStream { get } nonisolated var backCameraStream: AsyncStream { get } + func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws } /// Manages low-level camera access and stream production @@ -98,7 +99,40 @@ public final class DualCameraCameraStreamSource: NSObject, DualCameraCameraStrea nonisolated public var backCameraStream: AsyncStream { backBroadcaster.subscribe() } - + + /// Sets the torch (flashlight) mode for the specified camera. + /// - Parameters: + /// - mode: The desired torch mode (.on, .off, or .auto) + /// - camera: Which camera to control (front or back) + /// - Throws: DualCameraError if the device doesn't have a torch or configuration fails + @MainActor + public func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws { + let input: AVCaptureDeviceInput? + switch camera { + case .front: + input = frontCameraInput + case .back: + input = backCameraInput + } + + guard let device = input?.device else { + throw DualCameraError.cameraUnavailable(position: camera == .front ? .front : .back) + } + + guard device.hasTorch else { + // Front camera usually doesn't have torch, silently ignore + return + } + + do { + try device.lockForConfiguration() + device.torchMode = mode + device.unlockForConfiguration() + } catch { + throw DualCameraError.configurationFailed + } + } + // MARK: - Private Methods @MainActor @@ -208,6 +242,10 @@ extension DualCameraCameraStreamSource: AVCaptureVideoDataOutputSampleBufferDele public final class DualCameraMockCameraStreamSource: DualCameraCameraStreamSourcing { + public func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws { + + } + private let frontBroadcaster = PixelBufferBroadcaster() private let backBroadcaster = PixelBufferBroadcaster() diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 8399b8b..6be70ab 100644 --- a/Sources/DualCameraKit/DualCameraController.swift +++ b/Sources/DualCameraKit/DualCameraController.swift @@ -9,7 +9,7 @@ public protocol DualCameraControlling { func getRenderer(for source: DualCameraSource) -> CameraRenderer func startSession() async throws func stopSession() - + var photoCapturer: any DualCameraPhotoCapturing { get } func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage @@ -20,6 +20,8 @@ public protocol DualCameraControlling { func setVideoRecorder(_ recorder: any DualCameraVideoRecording) async throws func startVideoRecording(mode: DualCameraVideoRecordingMode) async throws func stopVideoRecording() async throws -> URL + + func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws } // default implementations for `DualCameraVideoRecorder` - proxy to implementation in `videoRecorder` @@ -102,7 +104,11 @@ public final class DualCameraController: DualCameraControlling { // Clear renderers so they're recreated with fresh stream connections on next startSession() renderers.removeAll() } - + + public func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws { + try streamSource.setTorchMode(mode, for: camera) + } + /// Creates a renderer (using MetalCameraRenderer by default). public func createRenderer() -> CameraRenderer { return MetalCameraRenderer() @@ -154,6 +160,10 @@ public final class DualCameraController: DualCameraControlling { /// via mocks. I think we'll evolve this here such that the DualCameraController can take mocked implementations /// and we can remove the need for this "fake" behavior, i.e., make things more consistent. public final class DualCameraMockController: DualCameraControlling { + public func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws { + + } + public init() { // for now, unmocked - we'll probably revisit this for testing. diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index d9f5cee..e6b6b72 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -23,6 +23,7 @@ public struct DualCameraScreen: View { .overlay(viewModel.isSettingsButtonVisible ? settingsButton : nil, alignment: .topLeading) .overlay(recordingIndicator, alignment: .top) .overlay(controlButtons, alignment: .bottom) + .overlay(accessoryItems, alignment: .trailing) if case .error(let error) = viewModel.viewState { errorOverlay(error) @@ -68,7 +69,6 @@ public struct DualCameraScreen: View { } // MARK: - View Components - @ViewBuilder private var recordingIndicator: some View { if case .recording(let state) = viewModel.viewState { @@ -95,6 +95,23 @@ public struct DualCameraScreen: View { } } + private var accessoryItems: some View { + VStack { + if viewModel.showCameraFlashButton { + Button { + viewModel.toggleFlashButtonTapped() + } label: { + Image(systemName: viewModel.flashMode.systemImageName) + } + } + } + .font(.largeTitle) + .tint(.primary) + .foregroundStyle(.white, .primary.opacity(0.5)) + .padding(.horizontal) + .opacity(viewModel.viewState.captureInProgress ? 0 : 1.0) + } + @ViewBuilder private var controlButtons: some View { VStack(spacing: 16) { @@ -126,7 +143,6 @@ public struct DualCameraScreen: View { } } .opacity(viewModel.viewState.captureInProgress ? 0 : 1) - //.padding(.bottom, 30) } @ViewBuilder @@ -199,19 +215,20 @@ public struct DualCameraScreen: View { // MARK: - Preview +#Preview("Photo") { + DualCameraScreen(viewModel: .init( + includeVideoRecording: false, + )) +} + #Preview("Photo & Video") { DualCameraScreen() } #Preview("Photo & Video - Show Settings Button") { DualCameraScreen(viewModel: .init( - showSettingsButton: false + showSettingsButton: true )) } -#Preview("Photo") { - DualCameraScreen(viewModel: .init( - showSettingsButton: false - )) -} diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index beac99a..34871e5 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -32,6 +32,21 @@ public enum DualCameraRecorderType: String, Equatable, CaseIterable, Identifiabl public var displayName: String { rawValue } } +/// Flash/torch mode for photo capture and video recording. +/// - `off`: Torch disabled +/// - `on`: Torch always enabled (for photos: brief flash, for video: continuous torch) +public enum CameraFlashMode: String, Equatable, CaseIterable { + case off + case on + + var systemImageName: String { + switch self { + case .off: return "bolt.slash.circle.fill" + case .on: return "bolt.circle.fill" + } + } +} + @MainActor @Observable public final class DualCameraViewModel { @@ -42,7 +57,7 @@ public final class DualCameraViewModel { var cameraLayout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing) // Size and position tracking - var containerSize: CGSize = .zero + var containerSize: CGSize = .zero // TODO: remove? /// The frame of the DualCameraScreen view in global window coordinates. /// Used for container-mode capture to crop the screenshot to the visible camera area. var containerFrame: CGRect = .zero @@ -69,6 +84,10 @@ public final class DualCameraViewModel { var isVideoButtonVisible: Bool { includeVideoRecording } var isSettingsButtonVisible: Bool + /// Current flash/torch mode + private(set) var flashMode: CameraFlashMode = .off + var showCameraFlashButton: Bool + private var recordingTimer: Timer? private let includeVideoRecording: Bool private let mediaLibraryService: MediaLibraryService @@ -79,9 +98,10 @@ public final class DualCameraViewModel { captureScope: CaptureScope = .fullScreen, videoRecorderMode: DualCameraRecorderType = .cpuBased, includeVideoRecording: Bool = true, - saveToLibrary: Bool = true, + saveToLibrary: Bool = false, mediaLibraryService: MediaLibraryService = CurrentDualCameraEnvironment.mediaLibraryService, - showSettingsButton: Bool = false + showSettingsButton: Bool = false, + showCameraFlashButton: Bool = true, ) { self.controller = dualCameraController self.cameraLayout = layout @@ -91,6 +111,7 @@ public final class DualCameraViewModel { self.saveToLibrary = saveToLibrary self.mediaLibraryService = mediaLibraryService self.isSettingsButtonVisible = showSettingsButton + self.showCameraFlashButton = showCameraFlashButton } // MARK: - Lifecycle Management @@ -126,11 +147,17 @@ public final class DualCameraViewModel { if case .recording = viewState { stopRecording() } + // Ensure torch is off when leaving the screen + try? controller.setTorchMode(.off, for: .back) controller.stopSession() recordingTimer?.invalidate() recordingTimer = nil } + func toggleFlashButtonTapped() { + flashMode = flashMode == .on ? .off : .on + } + // MARK: - Configuration Updates public func containerSizeChanged(_ newSize: CGSize) { @@ -169,10 +196,20 @@ public final class DualCameraViewModel { viewState = .capturing do { + // Turn on torch if flash is enabled (back camera only has torch) + if flashMode == .on { + try? controller.setTorchMode(.on, for: .back) + } + try await Task.sleep(for: .seconds(0.25)) let image = try await controller.captureCurrentScreen(mode: selectedCaptureScope.toPhotoCaptureMode(using: containerFrame)) viewState = .ready + // Turn off torch after capture + if flashMode == .on { + try? controller.setTorchMode(.off, for: .back) + } + // Expose captured photo for consumers to observe self.capturedPhoto = image @@ -183,11 +220,15 @@ public final class DualCameraViewModel { self.provideSaveSuccessHapticFeedback() } catch let error as DualCameraError { + // Ensure torch is off even on error + try? controller.setTorchMode(.off, for: .back) viewState = .error(error) showError(error, message: "Error capturing photo") // Reset to ready state after error viewState = .ready } catch { + // Ensure torch is off even on error + try? controller.setTorchMode(.off, for: .back) let dualCameraError = DualCameraError.unknownError viewState = .error(dualCameraError) showError(error, message: "Error capturing photo") @@ -219,10 +260,15 @@ public final class DualCameraViewModel { Task { viewState = .precapture do { + // Turn on torch if flash is enabled (continuous light for video) + if flashMode == .on { + try? controller.setTorchMode(.on, for: .back) + } + try await controller.startVideoRecording(mode: effectiveRecorderMode) - + viewState = .recording(CameraViewState.RecordingState(duration: 0)) - + // Start a timer to update recording duration recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in @@ -232,12 +278,16 @@ public final class DualCameraViewModel { } } } - + } catch let error as DualCameraError { + // Turn off torch on error + try? controller.setTorchMode(.off, for: .back) viewState = .error(error) showError(error, message: "Failed to start recording") viewState = .ready } catch { + // Turn off torch on error + try? controller.setTorchMode(.off, for: .back) let dualCameraError = DualCameraError.unknownError viewState = .error(dualCameraError) showError(error, message: "Failed to start recording") @@ -256,6 +306,11 @@ public final class DualCameraViewModel { do { let videoRecordingOutputURL = try await controller.stopVideoRecording() + // Turn off torch after recording stops + if flashMode == .on { + try? controller.setTorchMode(.off, for: .back) + } + // Reset recording state viewState = .ready @@ -269,10 +324,14 @@ public final class DualCameraViewModel { self.provideSaveSuccessHapticFeedback() } catch let error as DualCameraError { + // Ensure torch is off even on error + try? controller.setTorchMode(.off, for: .back) viewState = .error(error) showError(error, message: "Failed to stop recording") viewState = .ready } catch { + // Ensure torch is off even on error + try? controller.setTorchMode(.off, for: .back) let dualCameraError = DualCameraError.unknownError viewState = .error(dualCameraError) showError(error, message: "Failed to stop recording")