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 c7529ae..0bbf4be 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -11,56 +11,106 @@ 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 { @State private var selectedTab: Tab = .camera - - let vm = DualCameraViewModel( - captureScope: .container -// photoSaveStrategy: photoSaveStrategy - ) + @State private var captureReviewState = CaptureReviewState() + private var vm: DualCameraViewModel + + init() { + vm = DualCameraViewModel( + captureScope: .container, + includeVideoRecording: false, + saveToLibrary: false + ) + } 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) } - 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)) + } + } + .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) @@ -79,17 +129,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..beac99a 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) } @@ -264,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 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) }