From 6eeecc1dd6ae60556a60423aff8a66fafe523291 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 3 Apr 2025 12:52:10 -0700 Subject: [PATCH 1/9] add container example (layout bug) --- DualCameraDemo/DualCameraDemo/ContainerExample.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index c7529ae..089b8a9 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -20,7 +20,6 @@ private struct AppTabView: View { let vm = DualCameraViewModel( captureScope: .container -// photoSaveStrategy: photoSaveStrategy ) var body: some View { From 17627ad906daad4611cf652f0b67ae8ba78566b2 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 3 Apr 2025 14:22:56 -0700 Subject: [PATCH 2/9] container view capture mvp --- DualCameraDemo/DualCameraDemo/ContainerExample.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 089b8a9..c7a73f1 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -17,6 +17,8 @@ let photoSaveStrategy: DualCameraPhotoSaveStrategy = .custom { image in private struct AppTabView: View { @State private var selectedTab: Tab = .camera + // TODO: can/should this be dynamic? + @State private var tabBarHeight: CGFloat = 130 let vm = DualCameraViewModel( captureScope: .container From 2b196bdb3cbd99785fea439d4c2fc9fc5c0db41d Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Fri, 4 Apr 2025 19:48:33 -0700 Subject: [PATCH 3/9] cleanup --- .../DualCameraCPUVideoRecorderManager.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift index 197b2ba..3dd8feb 100644 --- a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift +++ b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift @@ -3,16 +3,16 @@ import CoreVideo import UIKit public struct DualCameraCPUVideoRecorderConfig: Sendable, Equatable { - public let mode: DualCameraPhotoCaptureMode +// public let mode: DualCameraPhotoCaptureMode public let quality: VideoQuality public let outputURL: URL? public init( - photoCaptureMode: DualCameraPhotoCaptureMode, +// photoCaptureMode: DualCameraPhotoCaptureMode, quality: VideoQuality = .high, outputURL: URL? = nil ) { - self.mode = photoCaptureMode +// self.mode = photoCaptureMode self.quality = quality self.outputURL = outputURL } @@ -55,9 +55,9 @@ public actor DualCameraCPUVideoRecorderManager: DualCameraVideoRecording { // Configuration nonisolated private let photoCapturer: any DualCameraPhotoCapturing private let config: DualCameraCPUVideoRecorderConfig - private var photoCaptureMode: DualCameraPhotoCaptureMode { - config.mode - } +// private var photoCaptureMode: DualCameraPhotoCaptureMode { +// config.mode +// } public init( photoCapturer: any DualCameraPhotoCapturing, From 07e0ae4bff8478e0003a387d2c48c9a53710dd0e Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Mon, 14 Apr 2025 12:09:19 -0700 Subject: [PATCH 4/9] cleanup a bit --- DualCameraDemo/DualCameraDemo/ContainerExample.swift | 2 -- Sources/DualCameraKit/DualCameraController.swift | 5 ++++- Sources/DualCameraKit/Screen/DualCameraScreen.swift | 1 + .../DualCameraKit/Screen/DualCameraViewModel.swift | 7 +++++++ .../DualCameraCPUVideoRecorderManager.swift | 12 ++++++------ 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index c7a73f1..089b8a9 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -17,8 +17,6 @@ let photoSaveStrategy: DualCameraPhotoSaveStrategy = .custom { image in private struct AppTabView: View { @State private var selectedTab: Tab = .camera - // TODO: can/should this be dynamic? - @State private var tabBarHeight: CGFloat = 130 let vm = DualCameraViewModel( captureScope: .container diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 8399b8b..2a55159 100644 --- a/Sources/DualCameraKit/DualCameraController.swift +++ b/Sources/DualCameraKit/DualCameraController.swift @@ -89,11 +89,14 @@ public final class DualCameraController: DualCameraControlling { } public func startSession() async throws { + print("1") try await streamSource.startSession() - + print("2") // Auto-initialize renderers _ = getRenderer(for: .front) + print("3") _ = getRenderer(for: .back) + print("4") } public func stopSession() { diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 10fc63d..cfe232b 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -29,6 +29,7 @@ public struct DualCameraScreen: View { } } .onAppear { + print("initial size", geoProxy.size ) viewModel.onAppear(containerSize: geoProxy.size) } .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index f24bb76..d60814a 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -92,10 +92,15 @@ public final class DualCameraViewModel { } private func startSession() { + print("startSEssion() called") Task { do { viewState = .loading try await controller.startSession() + if Task.isCancelled { + print("Task was cancelled before finishing") + } + print("session start!") viewState = .ready } catch let error as DualCameraError { viewState = .error(error) @@ -138,6 +143,7 @@ public final class DualCameraViewModel { func capturePhotoButtonTapped() { Task { + print("capturePhotoButtonTapped viewSTate", viewState) guard case .ready = viewState else { return } viewState = .capturing @@ -279,6 +285,7 @@ extension DualCameraViewModel { extension CameraViewState { // Button state helpers var isPhotoButtonEnabled: Bool { + print("isPhotoButtonEnabled", self) if case .ready = self { return true } return false } diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift index 3dd8feb..197b2ba 100644 --- a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift +++ b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift @@ -3,16 +3,16 @@ import CoreVideo import UIKit public struct DualCameraCPUVideoRecorderConfig: Sendable, Equatable { -// public let mode: DualCameraPhotoCaptureMode + public let mode: DualCameraPhotoCaptureMode public let quality: VideoQuality public let outputURL: URL? public init( -// photoCaptureMode: DualCameraPhotoCaptureMode, + photoCaptureMode: DualCameraPhotoCaptureMode, quality: VideoQuality = .high, outputURL: URL? = nil ) { -// self.mode = photoCaptureMode + self.mode = photoCaptureMode self.quality = quality self.outputURL = outputURL } @@ -55,9 +55,9 @@ public actor DualCameraCPUVideoRecorderManager: DualCameraVideoRecording { // Configuration nonisolated private let photoCapturer: any DualCameraPhotoCapturing private let config: DualCameraCPUVideoRecorderConfig -// private var photoCaptureMode: DualCameraPhotoCaptureMode { -// config.mode -// } + private var photoCaptureMode: DualCameraPhotoCaptureMode { + config.mode + } public init( photoCapturer: any DualCameraPhotoCapturing, From 8e279926ad519d28acb54c91f45f50b86602789f Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Fri, 23 May 2025 13:20:27 -0700 Subject: [PATCH 5/9] layout tweaks - optionally hide vidBTN, settingsBTN --- Sources/DualCameraKit/DualCameraController.swift | 5 +---- Sources/DualCameraKit/Screen/DualCameraViewModel.swift | 7 ------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 2a55159..8399b8b 100644 --- a/Sources/DualCameraKit/DualCameraController.swift +++ b/Sources/DualCameraKit/DualCameraController.swift @@ -89,14 +89,11 @@ public final class DualCameraController: DualCameraControlling { } public func startSession() async throws { - print("1") try await streamSource.startSession() - print("2") + // Auto-initialize renderers _ = getRenderer(for: .front) - print("3") _ = getRenderer(for: .back) - print("4") } public func stopSession() { diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index d60814a..f24bb76 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -92,15 +92,10 @@ public final class DualCameraViewModel { } private func startSession() { - print("startSEssion() called") Task { do { viewState = .loading try await controller.startSession() - if Task.isCancelled { - print("Task was cancelled before finishing") - } - print("session start!") viewState = .ready } catch let error as DualCameraError { viewState = .error(error) @@ -143,7 +138,6 @@ public final class DualCameraViewModel { func capturePhotoButtonTapped() { Task { - print("capturePhotoButtonTapped viewSTate", viewState) guard case .ready = viewState else { return } viewState = .capturing @@ -285,7 +279,6 @@ extension DualCameraViewModel { extension CameraViewState { // Button state helpers var isPhotoButtonEnabled: Bool { - print("isPhotoButtonEnabled", self) if case .ready = self { return true } return false } From 15de79f2d16ca94b1da976d8620a92754a10e2eb Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 10:55:57 -0700 Subject: [PATCH 6/9] fix stream restarting on return --- Sources/DualCameraKit/Screen/DualCameraScreen.swift | 1 - Sources/DualCameraKit/Screen/DualCameraViewModel.swift | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index cfe232b..10fc63d 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -29,7 +29,6 @@ public struct DualCameraScreen: View { } } .onAppear { - print("initial size", geoProxy.size ) viewModel.onAppear(containerSize: geoProxy.size) } .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index f24bb76..15535d2 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -89,6 +89,7 @@ public final class DualCameraViewModel { public func onAppear(containerSize: CGSize) { self.containerSize = containerSize startSession() + print("~~ onAppear DCVM called") } private func startSession() { From 2272c3891df4c2d772a2e0d7666393b62ce75b3f Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 11:00:05 -0700 Subject: [PATCH 7/9] cleanups --- Sources/DualCameraKit/Screen/DualCameraViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index 15535d2..f24bb76 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -89,7 +89,6 @@ public final class DualCameraViewModel { public func onAppear(containerSize: CGSize) { self.containerSize = containerSize startSession() - print("~~ onAppear DCVM called") } private func startSession() { From 986b0f7c7be7155360a8b6f8fa7e4565d07d9887 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 12:42:40 -0700 Subject: [PATCH 8/9] container mode captures context of container --- .../CapturePreviewOverlay.swift | 48 +++++++++ .../DualCameraDemo/ContainerExample.swift | 101 +++++++++++++----- .../DualCameraPhotoCapturing.swift | 37 +++++-- .../Screen/DualCameraConfigView.swift | 7 +- .../Screen/DualCameraScreen.swift | 17 ++- .../Screen/DualCameraViewModel.swift | 85 +++++++++++---- .../DualCameraCPUVideoRecorderManager.swift | 4 +- .../DualCameraViewModelTests.swift | 19 ++-- 8 files changed, 237 insertions(+), 81 deletions(-) create mode 100644 DualCameraDemo/DualCameraDemo/CapturePreviewOverlay.swift diff --git a/DualCameraDemo/DualCameraDemo/CapturePreviewOverlay.swift b/DualCameraDemo/DualCameraDemo/CapturePreviewOverlay.swift new file mode 100644 index 0000000..ec6e8fb --- /dev/null +++ b/DualCameraDemo/DualCameraDemo/CapturePreviewOverlay.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct CapturePreviewOverlay: View { + let image: UIImage + let onDismiss: () -> Void + let onConfirm: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + + VStack(spacing: 16) { + HStack { + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.white) + } + + Spacer() + + Text("Review") + .font(.headline) + .foregroundColor(.white) + + Spacer() + + Button(action: onConfirm) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + } + } + .padding() + + Image(uiImage: image) + .resizable() + .scaledToFit() + .cornerRadius(12) + .padding() + + Spacer() + } + .padding() + } + } +} diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 089b8a9..8e708bd 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -11,8 +11,26 @@ enum Tab { case feed, camera, map } -let photoSaveStrategy: DualCameraPhotoSaveStrategy = .custom { image in - print("captured", image) +@Observable +final class CaptureReviewState { + enum PreviewPhase: Equatable { + case hidden + case showing(UIImage) + } + var previewPhase = PreviewPhase.hidden + + func showPreview(_ image: UIImage) { + withAnimation { + previewPhase = .showing(image) + } + } + + func reset() { + withAnimation { + previewPhase = .hidden + } + + } } private struct AppTabView: View { @@ -23,43 +41,70 @@ private struct AppTabView: View { ) var body: some View { - VStack { - Group { + ZStack { + VStack { switch selectedTab { case .feed: - VStack { - Color.mint - } + feedMock case .camera: - ZStack { - GeometryReader { proxy in - DualCameraScreen( - viewModel: vm - ) - .onChange(of: proxy.size, initial: true) { _, newSize in - vm.containerSizeChanged(newSize) - } - } - } + cameraCapture case .map: - VStack { - Color.teal - } + mapMock } + tabBar + .edgesIgnoringSafeArea(.all) + } + + switch captureReviewState.previewPhase { + case .hidden: + EmptyView() + case .showing(let image): + CapturePreviewOverlay( + image: image, + onDismiss: { + captureReviewState.reset() + }, + onConfirm: { + captureReviewState.reset() + } + ) + .transition(.scale.combined(with: .opacity)) } - tabBar - .edgesIgnoringSafeArea(.all) + } + .background(.primary) + .onChange(of: vm.capturedPhoto) { oldValue, newValue in + if let image = newValue { + captureReviewState.showPreview(image) + } + } + } + + private var cameraCapture: some View { + DualCameraScreen( + viewModel: vm + ) + } + + private var feedMock: some View { + VStack { + Color(.systemMint) + } + } + + private var mapMock: some View { + VStack { + Color(.systemTeal) } } @ViewBuilder private var tabBar: some View { HStack { - tabBarButton(tab: .feed, image: "house.fill") + tabBarButton(tab: .map, image: "map.fill") Spacer() - tabBarButton(tab: .camera, image: "camera.fill", isCenter: true) + tabBarButton(tab: .camera, image: "house.fill", isCenter: true) Spacer() - tabBarButton(tab: .map, image: "bubble.left.and.bubble.right.fill") + tabBarButton(tab: .feed, image: "bubble.left.and.bubble.right.fill") } .padding(.horizontal, 30) .padding(.vertical, 10) @@ -78,17 +123,17 @@ private struct AppTabView: View { if isCenter { ZStack { Circle() - .fill(Color.white) + .fill(Color(.systemBackground)) .frame(width: 60, height: 60) .shadow(radius: 4) Image(systemName: image) .font(.system(size: 24)) - .foregroundColor(.black) + .foregroundColor(Color(.label)) } } else { Image(systemName: image) .font(.system(size: 24)) - .foregroundColor(selectedTab == tab ? .blue : .gray) + .foregroundColor(selectedTab == tab ? Color.accentColor : Color(.secondaryLabel)) } } } diff --git a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift index 46f6779..48684f9 100644 --- a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift +++ b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift @@ -6,10 +6,21 @@ public protocol DualCameraPhotoCapturing: AnyObject, Sendable { func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage } -/// determines whether the photos are captured in as if displayed in `fullScreen` or in a layout not fillingl the fullscreen aka a container via `containerSize` +/// Determines the capture mode for photo screenshots. +/// +/// - `fullScreen`: Captures the entire screen +/// - `containerFrame`: Captures only the specified frame region (used for container mode) public enum DualCameraPhotoCaptureMode: Sendable, Equatable { case fullScreen - case containerSize(CGSize) + /// Captures a specific rectangular region of the screen in global window coordinates. + /// The frame's origin determines the top-left corner to start capturing from, + /// and the size determines the dimensions of the captured area. + case containerFrame(CGRect) + + @available(*, deprecated, message: "Use containerFrame instead") + public static func containerSize(_ size: CGSize) -> DualCameraPhotoCaptureMode { + .containerFrame(CGRect(origin: .zero, size: size)) + } } public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { @@ -73,27 +84,33 @@ public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { } return capturedImage - case .containerSize(let size): - guard !size.width.isZero && !size.height.isZero else { + case .containerFrame(let frame): + guard !frame.size.width.isZero && !frame.size.height.isZero else { throw DualCameraError.captureFailure(.unknownDimensions) } - + + let format = UIGraphicsImageRendererFormat() format.scale = screenScale format.opaque = true - + // Create renderer with optimized format for container size - let renderer = UIGraphicsImageRenderer(size: size, format: format) - - // Generate scaled image with optimized drawing + let renderer = UIGraphicsImageRenderer(size: frame.size, format: format) + + // Generate cropped image by translating the drawing context let capturedImage = renderer.image { context in let cgContext = context.cgContext - + + // Translate the context to "shift" the window so the desired frame is at origin + cgContext.translateBy(x: -frame.origin.x, y: -frame.origin.y) + + // Draw the full window hierarchy, but only the translated portion will be visible keyWindow.drawHierarchy( in: CGRect(origin: .zero, size: fullScreenSize), afterScreenUpdates: afterScreenUpdates ) } + return capturedImage } } diff --git a/Sources/DualCameraKit/Screen/DualCameraConfigView.swift b/Sources/DualCameraKit/Screen/DualCameraConfigView.swift index 4ad7020..875bcf0 100644 --- a/Sources/DualCameraKit/Screen/DualCameraConfigView.swift +++ b/Sources/DualCameraKit/Screen/DualCameraConfigView.swift @@ -94,12 +94,7 @@ struct DualCameraConfigView: View { DualCameraConfigView( viewModel: DualCameraViewModel( dualCameraController: DualCameraMockController(), - videoSaveStrategy: .custom({ savedFile in - print("video recorded: \(savedFile)") - }), - photoSaveStrategy: .custom( {capturedImage in - print("photo captured: \(capturedImage)") - }) + saveToLibrary: false ) ) } diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 10fc63d..d9f5cee 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -23,7 +23,7 @@ public struct DualCameraScreen: View { .overlay(viewModel.isSettingsButtonVisible ? settingsButton : nil, alignment: .topLeading) .overlay(recordingIndicator, alignment: .top) .overlay(controlButtons, alignment: .bottom) - + if case .error(let error) = viewModel.viewState { errorOverlay(error) } @@ -31,7 +31,7 @@ public struct DualCameraScreen: View { .onAppear { viewModel.onAppear(containerSize: geoProxy.size) } - .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in + .onChange(of: geoProxy.size, initial: true) { oldSize, newSize in viewModel.containerSizeChanged(newSize) } .onDisappear { @@ -52,6 +52,18 @@ public struct DualCameraScreen: View { .overlay(alignment: .top) { customOverlay(viewModel) } + .background( + GeometryReader { innerProxy in + Color.clear + .onAppear { + let globalFrame = innerProxy.frame(in: .global) + viewModel.containerFrameChanged(globalFrame) + } + .onChange(of: innerProxy.frame(in: .global)) { oldFrame, newFrame in + viewModel.containerFrameChanged(newFrame) + } + } + ) } } @@ -199,7 +211,6 @@ public struct DualCameraScreen: View { #Preview("Photo") { DualCameraScreen(viewModel: .init( - videoSaveStrategy: nil, showSettingsButton: false )) } diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index f24bb76..c12dfea 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -13,12 +13,12 @@ public enum CaptureScope: Equatable { } } - func toPhotoCaptureMode(using size: CGSize) -> DualCameraPhotoCaptureMode { + func toPhotoCaptureMode(using frame: CGRect) -> DualCameraPhotoCaptureMode { switch self { case .fullScreen: return .fullScreen case .container: - return .containerSize(size) + return .containerFrame(frame) } } } @@ -41,53 +41,66 @@ public final class DualCameraViewModel { public var isCameraViewStateCapturing: Bool { viewState.captureInProgress } var cameraLayout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing) - // Size tracking + // Size and position tracking var containerSize: CGSize = .zero + /// 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 // Recording configuration private(set) var selectedRecorderType: DualCameraRecorderType private(set) var selectedCaptureScope: CaptureScope - // User artifacts - private(set) var capturedImage: UIImage? = nil + // User artifacts - exposed for consumers to observe + /// The most recently captured photo. Consumers should use `.onChange(of:)` to observe new captures. + public private(set) var capturedPhoto: UIImage? = nil + /// The most recently recorded video URL. Consumers should use `.onChange(of:)` to observe new recordings. + public private(set) var capturedVideo: URL? = nil var alert: AlertState? = nil - + enum SheetType: String, Identifiable { var id: String { self.rawValue } case configSheet } var presentedSheet: SheetType? - + let controller: DualCameraControlling - var isVideoButtonVisible: Bool { videoSaveStrategy != nil } + private let saveToLibrary: Bool + var isVideoButtonVisible: Bool { includeVideoRecording } var isSettingsButtonVisible: Bool private var recordingTimer: Timer? - private var videoSaveStrategy: DualCameraVideoSaveStrategy? - private var photoSaveStrategy: DualCameraPhotoSaveStrategy + private let includeVideoRecording: Bool + private let mediaLibraryService: MediaLibraryService public init( dualCameraController: DualCameraControlling = CurrentDualCameraEnvironment.dualCameraController, layout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing), captureScope: CaptureScope = .fullScreen, videoRecorderMode: DualCameraRecorderType = .cpuBased, - videoSaveStrategy: DualCameraVideoSaveStrategy? = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), - photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + includeVideoRecording: Bool = true, + saveToLibrary: Bool = true, + mediaLibraryService: MediaLibraryService = CurrentDualCameraEnvironment.mediaLibraryService, showSettingsButton: Bool = false ) { self.controller = dualCameraController self.cameraLayout = layout self.selectedRecorderType = videoRecorderMode self.selectedCaptureScope = captureScope + self.includeVideoRecording = includeVideoRecording + self.saveToLibrary = saveToLibrary + self.mediaLibraryService = mediaLibraryService self.isSettingsButtonVisible = showSettingsButton - self.videoSaveStrategy = videoSaveStrategy - self.photoSaveStrategy = photoSaveStrategy } // MARK: - Lifecycle Management public func onAppear(containerSize: CGSize) { self.containerSize = containerSize + // Initialize frame with size at origin (will be updated by PreferenceKey with actual position) + if self.containerFrame == .zero { + self.containerFrame = CGRect(origin: .zero, size: containerSize) + } startSession() } @@ -122,6 +135,20 @@ public final class DualCameraViewModel { public func containerSizeChanged(_ newSize: CGSize) { self.containerSize = newSize + // Update frame size while preserving origin (if already set) + if containerFrame != .zero { + self.containerFrame = CGRect(origin: containerFrame.origin, size: newSize) + } else { + self.containerFrame = CGRect(origin: .zero, size: newSize) + } + } + + /// Updates the container frame when the view's position or size changes in the window. + /// This is automatically called by DualCameraScreen's GeometryReader. + /// - Parameter newFrame: The new frame in global window coordinates + public func containerFrameChanged(_ newFrame: CGRect) { + self.containerFrame = newFrame + self.containerSize = newFrame.size } func updateLayout(_ newLayout: DualCameraLayout) { @@ -140,12 +167,20 @@ public final class DualCameraViewModel { Task { guard case .ready = viewState else { return } viewState = .capturing - + do { try await Task.sleep(for: .seconds(0.25)) - let image = try await controller.captureCurrentScreen(mode: selectedCaptureScope.toPhotoCaptureMode(using: containerSize)) + let image = try await controller.captureCurrentScreen(mode: selectedCaptureScope.toPhotoCaptureMode(using: containerFrame)) viewState = .ready - try await self.photoSaveStrategy.save(image) + + // Expose captured photo for consumers to observe + self.capturedPhoto = image + + // Optionally save to library + if saveToLibrary { + try await mediaLibraryService.saveImage(image) + } + self.provideSaveSuccessHapticFeedback() } catch let error as DualCameraError { viewState = .error(error) @@ -215,16 +250,23 @@ public final class DualCameraViewModel { // Stop the timer recordingTimer?.invalidate() recordingTimer = nil - + // Stop recording Task { do { let videoRecordingOutputURL = try await controller.stopVideoRecording() - + // Reset recording state viewState = .ready - try await self.videoSaveStrategy?.save(videoRecordingOutputURL) + // Expose captured video for consumers to observe + self.capturedVideo = videoRecordingOutputURL + + // Optionally save to library + if saveToLibrary { + try await mediaLibraryService.saveVideo(videoRecordingOutputURL) + } + self.provideSaveSuccessHapticFeedback() } catch let error as DualCameraError { viewState = .error(error) @@ -242,7 +284,7 @@ public final class DualCameraViewModel { private var effectiveRecorderMode: DualCameraVideoRecordingMode { switch selectedRecorderType { case .cpuBased: - return .cpuBased(.init(photoCaptureMode: selectedCaptureScope.toPhotoCaptureMode(using: containerSize))) + return .cpuBased(.init(photoCaptureMode: selectedCaptureScope.toPhotoCaptureMode(using: containerFrame))) case .replayKit: // ReplayKit always uses full screen regardless of selected scope return .replayKit() @@ -253,7 +295,6 @@ public final class DualCameraViewModel { private func showError(_ error: Error, message: String) { let errorMessage = "\(message): \(error.localizedDescription)" - print(errorMessage) alert = .info(title: "Error", message: errorMessage) } diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift index 197b2ba..ac6abe1 100644 --- a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift +++ b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift @@ -180,8 +180,8 @@ public actor DualCameraCPUVideoRecorderManager: DualCameraVideoRecording { height: bounds.height * scale ) } - case .containerSize(let size): - rawSize = size + case .containerFrame(let frame): + rawSize = frame.size } // Apply resolution scaling to improve performance diff --git a/Tests/DualCameraKitTests/DualCameraViewModelTests.swift b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift index ee517d3..f32b941 100644 --- a/Tests/DualCameraKitTests/DualCameraViewModelTests.swift +++ b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift @@ -9,29 +9,28 @@ final class DualCameraViewModelTests: XCTestCase { func test_init_withDefaultParams_setsDefaultValues() async { let mockController = MockDualCameraController() CurrentDualCameraEnvironment.dualCameraController = mockController - + let viewModel = DualCameraViewModel() - + XCTAssertEqual(viewModel.viewState, .loading) - XCTAssertEqual(viewModel.configuration.layout, .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing)) - XCTAssertEqual(viewModel.videoRecorderType, .cpuBased(.init(photoCaptureMode: .fullScreen))) + XCTAssertEqual(viewModel.cameraLayout, .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing)) + XCTAssertEqual(viewModel.selectedRecorderType, .cpuBased) XCTAssertIdentical(viewModel.controller as? MockDualCameraController, mockController) } func test_init_withCustomParams_setsCustomValues() { let mockController = MockDualCameraController() let customLayout: DualCameraLayout = .sideBySide - let customRecorderMode: DualCameraVideoRecordingMode = .replayKit() + let customRecorderType: DualCameraRecorderType = .replayKit let viewModel = DualCameraViewModel( dualCameraController: mockController, layout: customLayout, - videoRecorderMode: customRecorderMode + videoRecorderMode: customRecorderType ) - - XCTAssertEqual(viewModel.configuration.layout, customLayout) - XCTAssertEqual(viewModel.videoRecorderType, customRecorderMode) + XCTAssertEqual(viewModel.cameraLayout, customLayout) + XCTAssertEqual(viewModel.selectedRecorderType, customRecorderType) } // MARK: - Lifecycle Tests @@ -46,7 +45,7 @@ final class DualCameraViewModelTests: XCTestCase { await Task.yield() XCTAssertTrue(mockController.sessionStarted) - XCTAssertEqual(viewModel.configuration.containerSize, CGSize(width: 390, height: 844)) + XCTAssertEqual(viewModel.containerSize, CGSize(width: 390, height: 844)) XCTAssertEqual(viewModel.viewState, .ready) } From d4f90c139a93b7fa4f013196b2146021a79a3deb Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 12:59:38 -0700 Subject: [PATCH 9/9] fix container demo --- .../DualCameraDemo/ContainerExample.swift | 14 ++++++++++---- .../DualCameraKit/Screen/DualCameraViewModel.swift | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 8e708bd..0bbf4be 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -35,10 +35,16 @@ final class CaptureReviewState { private struct AppTabView: View { @State private var selectedTab: Tab = .camera - - let vm = DualCameraViewModel( - captureScope: .container - ) + @State private var captureReviewState = CaptureReviewState() + private var vm: DualCameraViewModel + + init() { + vm = DualCameraViewModel( + captureScope: .container, + includeVideoRecording: false, + saveToLibrary: false + ) + } var body: some View { ZStack { diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index c12dfea..beac99a 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -305,14 +305,14 @@ public final class DualCameraViewModel { } } -// MARK: - Default init +// MARK: - Default init extension DualCameraViewModel { public static func `default`() -> DualCameraViewModel { return DualCameraViewModel( dualCameraController: CurrentDualCameraEnvironment.dualCameraController ) - } + } } // MARK: - UI State Helpers