Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion Sources/DualCameraKit/DualCameraCameraStreamSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public protocol DualCameraCameraStreamSourcing {
nonisolated func stopSession()
nonisolated var frontCameraStream: AsyncStream<PixelBufferWrapper> { get }
nonisolated var backCameraStream: AsyncStream<PixelBufferWrapper> { get }
func setTorchMode(_ mode: AVCaptureDevice.TorchMode, for camera: DualCameraSource) throws
}

/// Manages low-level camera access and stream production
Expand Down Expand Up @@ -98,7 +99,40 @@ public final class DualCameraCameraStreamSource: NSObject, DualCameraCameraStrea
nonisolated public var backCameraStream: AsyncStream<PixelBufferWrapper> {
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
Expand Down Expand Up @@ -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()

Expand Down
14 changes: 12 additions & 2 deletions Sources/DualCameraKit/DualCameraController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 25 additions & 8 deletions Sources/DualCameraKit/Screen/DualCameraScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -68,7 +69,6 @@ public struct DualCameraScreen: View {
}

// MARK: - View Components

@ViewBuilder
private var recordingIndicator: some View {
if case .recording(let state) = viewModel.viewState {
Expand All @@ -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) {
Expand Down Expand Up @@ -126,7 +143,6 @@ public struct DualCameraScreen: View {
}
}
.opacity(viewModel.viewState.captureInProgress ? 0 : 1)
//.padding(.bottom, 30)
}

@ViewBuilder
Expand Down Expand Up @@ -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
))
}

71 changes: 65 additions & 6 deletions Sources/DualCameraKit/Screen/DualCameraViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -91,6 +111,7 @@ public final class DualCameraViewModel {
self.saveToLibrary = saveToLibrary
self.mediaLibraryService = mediaLibraryService
self.isSettingsButtonVisible = showSettingsButton
self.showCameraFlashButton = showCameraFlashButton
}

// MARK: - Lifecycle Management
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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

Expand All @@ -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")
Expand Down
Loading