From 8dc95e2c9572115ae9f3ce67bb298f4aa3e23cf2 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 3 Apr 2025 12:52:10 -0700 Subject: [PATCH 1/7] add container example (layout bug) --- .../DualCameraDemo/ContainerExample.swift | 89 +++++++++++++++++++ .../DualCameraDemo/DualCameraDemoApp.swift | 15 +++- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 DualCameraDemo/DualCameraDemo/ContainerExample.swift diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift new file mode 100644 index 0000000..635838a --- /dev/null +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -0,0 +1,89 @@ +import DualCameraKit +import SwiftUI + +struct ContainerExample: View { + var body: some View { + AppTabView() + } +} + +enum Tab { + case feed, camera, map +} + +let photoSaveStrategy: DualCameraPhotoSaveStrategy = .custom { image in + print("captured", image) +} + +private struct AppTabView: View { + @State private var selectedTab: Tab = .camera + + let vm = DualCameraViewModel(photoSaveStrategy: photoSaveStrategy) + + var body: some View { + ZStack(alignment: .bottom) { + // Switch between the different views based on the selected tab. + Group { + switch selectedTab { + case .feed: + VStack { + Color.mint + } + case .camera: + DualCameraScreen( + viewModel: vm + ) + case .map: + VStack { + Color.teal + } + } + } + .edgesIgnoringSafeArea(.all) + + // Custom tab bar at the bottom. + HStack { + tabBarButton(tab: .feed, image: "house.fill") + Spacer() + tabBarButton(tab: .camera, image: "camera.fill", isCenter: true) + Spacer() + tabBarButton(tab: .map, image: "bubble.left.and.bubble.right.fill") + } + .padding(.horizontal, 30) + .padding(.vertical, 10) + .background(Color(UIColor.systemBackground).opacity(0.95)) + .cornerRadius(20) + .shadow(radius: 5) + .padding(.horizontal) + .padding(.bottom, 10) + } + } + + @ViewBuilder + private func tabBarButton(tab: Tab, image: String, isCenter: Bool = false) -> some View { + Button(action: { + selectedTab = tab + }) { + if isCenter { + // The center button is styled larger and in a circular shape. + ZStack { + Circle() + .fill(Color.white) + .frame(width: 60, height: 60) + .shadow(radius: 4) + Image(systemName: image) + .font(.system(size: 24)) + .foregroundColor(.black) + } + } else { + Image(systemName: image) + .font(.system(size: 24)) + .foregroundColor(selectedTab == tab ? .blue : .gray) + } + } + } +} + +#Preview { + AppTabView() +} diff --git a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift index 1e61a03..e61bf9b 100644 --- a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift +++ b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift @@ -5,18 +5,25 @@ import SwiftUI @main struct DualCameraDemoApp: App { enum DemoDisplayType { - case dualCameraScreen + case dualCameraScreen(photoCaptureMode: DualCameraPhotoCaptureMode) case dualCameraDisplayView case dualCameraLowLevelComponents } - @State private var demoType = DemoDisplayType.dualCameraScreen + @State private var demoType = DemoDisplayType.dualCameraScreen(photoCaptureMode: .fullScreen) var body: some Scene { WindowGroup { switch demoType { - case .dualCameraScreen: - DualCameraScreen() + case .dualCameraScreen(let photoCaptureMode): + switch photoCaptureMode { + case .fullScreen: + DualCameraScreen() + case .containerSize(let cGSize): + ContainerExample() + } + + case .dualCameraDisplayView, .dualCameraLowLevelComponents: Text("Not Implemented Yet") } From 8b8c50a0c8b4ccb1c14231046f22a04d3e33e430 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 3 Apr 2025 14:22:56 -0700 Subject: [PATCH 2/7] container view capture mvp --- .../DualCameraDemo/ContainerExample.swift | 23 +++++++++++++++---- .../DualCameraDemo/DualCameraDemoApp.swift | 14 +++++------ .../DualCameraPhotoCapturing.swift | 14 +++++------ .../Screen/DualCameraScreen.swift | 1 + .../Screen/DualCameraViewModel.swift | 2 +- .../DualCameraVideoRecordingMode.swift | 9 +++++++- .../MediaSaveStrategyTests.swift | 3 +-- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 635838a..9f8e9bb 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -17,8 +17,14 @@ 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(photoSaveStrategy: photoSaveStrategy) + let vm = DualCameraViewModel( + // TODO: cleanup this hard-coding; also move this config + videoRecorderMode: .cpuBased(.init(photoCaptureMode: .containerSize(.init(width: 393, height: 722)))) +// photoSaveStrategy: photoSaveStrategy + ) var body: some View { ZStack(alignment: .bottom) { @@ -30,9 +36,18 @@ private struct AppTabView: View { Color.mint } case .camera: - DualCameraScreen( - viewModel: vm - ) + ZStack { + GeometryReader { proxy in + DualCameraScreen( + viewModel: vm + ) + .onAppear { + print("proxy", proxy.size) + } + }.padding(.bottom, tabBarHeight) + + } + case .map: VStack { Color.teal diff --git a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift index e61bf9b..ae51be2 100644 --- a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift +++ b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift @@ -5,25 +5,23 @@ import SwiftUI @main struct DualCameraDemoApp: App { enum DemoDisplayType { - case dualCameraScreen(photoCaptureMode: DualCameraPhotoCaptureMode) + case dualCameraScreen(isFullScreen: Bool) case dualCameraDisplayView case dualCameraLowLevelComponents } - @State private var demoType = DemoDisplayType.dualCameraScreen(photoCaptureMode: .fullScreen) + @State private var demoType = DemoDisplayType.dualCameraScreen(isFullScreen: false) var body: some Scene { WindowGroup { switch demoType { - case .dualCameraScreen(let photoCaptureMode): - switch photoCaptureMode { - case .fullScreen: + case .dualCameraScreen(let isFullScreen): + switch isFullScreen { + case true: DualCameraScreen() - case .containerSize(let cGSize): + case false: ContainerExample() } - - case .dualCameraDisplayView, .dualCameraLowLevelComponents: Text("Not Implemented Yet") } diff --git a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift index 678ef3f..14f8d5f 100644 --- a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift +++ b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift @@ -41,7 +41,7 @@ public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { /// Returns an image that is a screenshot of the screen. public func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage { let application = UIApplication.shared - + print("mode is", mode) guard let keyWindow = application.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive })? @@ -91,12 +91,12 @@ public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { let cgContext = context.cgContext // Calculate scaling - let scaleX = size.width / fullScreenSize.width - let scaleY = size.height / fullScreenSize.height - let scale = min(scaleX, scaleY) - - // Apply scaling - cgContext.scaleBy(x: scale, y: scale) +// let scaleX = size.width / fullScreenSize.width +// let scaleY = size.height / fullScreenSize.height +// let scale = min(scaleX, scaleY) +// +// // Apply scaling +// cgContext.scaleBy(x: scale, y: scale) keyWindow.drawHierarchy( in: CGRect(origin: .zero, size: fullScreenSize), diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 00a03ae..7779267 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -172,6 +172,7 @@ public struct DualCameraScreen: View { .font(.title2) } .tint(.gray) + .opacity(viewModel.viewState.captureInProgress ? 0 : 1) .padding(.leading) } } diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index bb73d11..9cf1e53 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -104,7 +104,7 @@ public final class DualCameraViewModel { do { try await Task.sleep(for: .seconds(0.25)) - let image = try await controller.captureCurrentScreen() + let image = try await controller.captureCurrentScreen(mode: configuration.videoRecorderMode.photoCaptureMode) viewState = .ready try await self.photoSaveStrategy.save(image) self.provideSaveSuccessHapticFeedback() diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraVideoRecordingMode.swift b/Sources/DualCameraKit/VideoRecording/DualCameraVideoRecordingMode.swift index 21292a2..909d9ee 100644 --- a/Sources/DualCameraKit/VideoRecording/DualCameraVideoRecordingMode.swift +++ b/Sources/DualCameraKit/VideoRecording/DualCameraVideoRecordingMode.swift @@ -1,6 +1,5 @@ import Foundation - public enum DualCameraVideoRecordingMode: CaseIterable, Identifiable, Sendable, Equatable { /// For now, DualCameraKit is setup to handle these two recorders with these configs. /// Specifically, we are not yet formally support the .cpuBased(.init(mode: .fullScreen)) config though it may work (code is not tested yet). @@ -21,5 +20,13 @@ public enum DualCameraVideoRecordingMode: CaseIterable, Identifiable, Sendable, case .replayKit: "ReplayKit - Full ScreenCapture" } } + + public var photoCaptureMode: DualCameraPhotoCaptureMode { + return switch self { + case .replayKit(_): .fullScreen + case .cpuBased(let config): + config.mode + } + } } diff --git a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift index 6adcd43..2ff8226 100644 --- a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift +++ b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift @@ -55,11 +55,10 @@ final class MediaSaveStrategyTests: XCTestCase { let savedImage = await imageBox.get() XCTAssertEqual(savedImage, testImage) } - + func test_mediaLibraryStrategy_unimplementedFails() async { let strategy = DualCameraPhotoSaveStrategy.saveToMediaLibrary(MediaLibraryService.failing.saveImage) await XCTAssertThrowsError(ofType: MediaLibraryError.self, try await strategy.save(UIImage()) ) } - } From 0101190e133614607dc8caa1259d983ba5ae5bde Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Fri, 4 Apr 2025 19:48:33 -0700 Subject: [PATCH 3/7] cleanup --- DualCameraDemo/DualCameraDemo/ContainerExample.swift | 4 ++-- .../DualCameraDemo/DualCameraDemoApp.swift | 1 - .../DualCameraCPUVideoRecorderManager.swift | 12 ++++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 9f8e9bb..9953dea 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -44,8 +44,8 @@ private struct AppTabView: View { .onAppear { print("proxy", proxy.size) } - }.padding(.bottom, tabBarHeight) - + } + .padding(.bottom, tabBarHeight) } case .map: diff --git a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift index ae51be2..b341079 100644 --- a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift +++ b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift @@ -25,7 +25,6 @@ struct DualCameraDemoApp: App { case .dualCameraDisplayView, .dualCameraLowLevelComponents: Text("Not Implemented Yet") } - } } } diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift index da69280..59815ae 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 46d97471123e9081570e6830bf36b3590fae2e88 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Mon, 14 Apr 2025 12:09:19 -0700 Subject: [PATCH 4/7] cleanup a bit --- .../DualCameraDemo/ContainerExample.swift | 50 +++++----- .../DualCameraKit/DualCameraController.swift | 5 +- .../DualCameraKit/DualCameraDisplayView.swift | 17 ++-- .../Screen/CameraConfiguration.swift | 21 ---- .../Screen/DualCameraConfigView.swift | 8 +- .../Screen/DualCameraScreen.swift | 18 ++-- .../Screen/DualCameraViewModel.swift | 95 +++++++++++++++---- .../DualCameraCPUVideoRecorderManager.swift | 12 +-- 8 files changed, 132 insertions(+), 94 deletions(-) delete mode 100644 Sources/DualCameraKit/Screen/CameraConfiguration.swift diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift index 9953dea..c7529ae 100644 --- a/DualCameraDemo/DualCameraDemo/ContainerExample.swift +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -17,18 +17,14 @@ 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( - // TODO: cleanup this hard-coding; also move this config - videoRecorderMode: .cpuBased(.init(photoCaptureMode: .containerSize(.init(width: 393, height: 722)))) + captureScope: .container // photoSaveStrategy: photoSaveStrategy ) var body: some View { - ZStack(alignment: .bottom) { - // Switch between the different views based on the selected tab. + VStack { Group { switch selectedTab { case .feed: @@ -41,46 +37,46 @@ private struct AppTabView: View { DualCameraScreen( viewModel: vm ) - .onAppear { - print("proxy", proxy.size) + .onChange(of: proxy.size, initial: true) { _, newSize in + vm.containerSizeChanged(newSize) } } - .padding(.bottom, tabBarHeight) } - case .map: VStack { Color.teal } } } + tabBar .edgesIgnoringSafeArea(.all) - - // Custom tab bar at the bottom. - HStack { - tabBarButton(tab: .feed, image: "house.fill") - Spacer() - tabBarButton(tab: .camera, image: "camera.fill", isCenter: true) - Spacer() - tabBarButton(tab: .map, image: "bubble.left.and.bubble.right.fill") - } - .padding(.horizontal, 30) - .padding(.vertical, 10) - .background(Color(UIColor.systemBackground).opacity(0.95)) - .cornerRadius(20) - .shadow(radius: 5) - .padding(.horizontal) - .padding(.bottom, 10) } } + @ViewBuilder + private var tabBar: some View { + HStack { + tabBarButton(tab: .feed, image: "house.fill") + Spacer() + tabBarButton(tab: .camera, image: "camera.fill", isCenter: true) + Spacer() + tabBarButton(tab: .map, image: "bubble.left.and.bubble.right.fill") + } + .padding(.horizontal, 30) + .padding(.vertical, 10) + .background(Color(UIColor.systemBackground).opacity(0.95)) + .cornerRadius(20) + .shadow(radius: 5) + .padding(.horizontal) + .padding(.bottom, 10) + } + @ViewBuilder private func tabBarButton(tab: Tab, image: String, isCenter: Bool = false) -> some View { Button(action: { selectedTab = tab }) { if isCenter { - // The center button is styled larger and in a circular shape. ZStack { Circle() .fill(Color.white) diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 82758d1..051c300 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/DualCameraDisplayView.swift b/Sources/DualCameraKit/DualCameraDisplayView.swift index afe72c5..9c04576 100644 --- a/Sources/DualCameraKit/DualCameraDisplayView.swift +++ b/Sources/DualCameraKit/DualCameraDisplayView.swift @@ -27,7 +27,7 @@ public struct DualCameraDisplayView: View { for: (miniCamera == .front ? .back : .front) ) ) - .ignoresSafeArea(.all) +// .ignoresSafeArea(.all) // Mini camera in corner DualCameraRendererView(renderer: controller.getRenderer(for: miniCamera)) @@ -42,22 +42,23 @@ public struct DualCameraDisplayView: View { cameraView(for: .back, widthFraction: 0.5) cameraView(for: .front, widthFraction: 0.5) } - .ignoresSafeArea(.all) +// .ignoresSafeArea(.all) case .stackedVertical: VStack(spacing: 0) { cameraView(for: .back, heightFraction: 0.5) cameraView(for: .front, heightFraction: 0.5) } - .ignoresSafeArea(.all) +// .ignoresSafeArea(.all) } } .task { - do { - try await controller.startSession() - } catch { - print("Camera session error: \(error)") - } + // TODO: fixme - should View here depend on VM? this functionality led to bug in screen due to ctrl.startSession vs. vm.startSession +// do { +// try await controller.startSession() +// } catch { +// print("Camera session error: \(error)") +// } } } diff --git a/Sources/DualCameraKit/Screen/CameraConfiguration.swift b/Sources/DualCameraKit/Screen/CameraConfiguration.swift deleted file mode 100644 index bbe251d..0000000 --- a/Sources/DualCameraKit/Screen/CameraConfiguration.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Configuration options that persist across state changes -struct CameraConfiguration: Equatable { - var layout: DualCameraLayout - var containerSize: CGSize - var videoRecorderMode: DualCameraVideoRecordingMode - - init(layout: DualCameraLayout = .piP( - miniCamera: .front, miniCameraPosition: .bottomTrailing), - containerSize: CGSize = .zero, - videoRecorderMode: DualCameraVideoRecordingMode = - .cpuBased( - DualCameraCPUVideoRecorderConfig(photoCaptureMode: .fullScreen) - ) - ) { - self.layout = layout - self.containerSize = containerSize - self.videoRecorderMode = videoRecorderMode - } -} diff --git a/Sources/DualCameraKit/Screen/DualCameraConfigView.swift b/Sources/DualCameraKit/Screen/DualCameraConfigView.swift index 1400483..4ad7020 100644 --- a/Sources/DualCameraKit/Screen/DualCameraConfigView.swift +++ b/Sources/DualCameraKit/Screen/DualCameraConfigView.swift @@ -50,7 +50,7 @@ struct DualCameraConfigView: View { } label: { HStack { Text(title) - if viewModel.configuration.layout == layout { + if viewModel.cameraLayout == layout { Image(systemName: "checkmark") } } @@ -62,13 +62,13 @@ struct DualCameraConfigView: View { private var recorderTypePicker: some View { VStack { Menu { - ForEach(DualCameraVideoRecordingMode.allCases) { recorderType in + ForEach(DualCameraRecorderType.allCases) { recorderType in Button { viewModel.toggleRecorderType() } label: { HStack { Text(recorderType.displayName) - if viewModel.videoRecorderType == recorderType { + if viewModel.selectedRecorderType == recorderType { Image(systemName: "checkmark") } } @@ -77,7 +77,7 @@ struct DualCameraConfigView: View { } label: { HStack { Image(systemName: "video.fill") - Text("Recorder: \(viewModel.videoRecorderType.displayName)") + Text("Recorder: \(viewModel.selectedRecorderType.displayName)") Image(systemName: "chevron.up.chevron.down") .font(.caption) } diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 7779267..4c7a33e 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -14,8 +14,9 @@ public struct DualCameraScreen: View { ZStack { DualCameraDisplayView( controller: viewModel.controller, - layout: viewModel.configuration.layout + layout: viewModel.cameraLayout ) + .ignoresSafeArea() .overlay(settingsButton, alignment: .topLeading) .overlay(recordingIndicator, alignment: .top) .overlay(controlButtons, alignment: .bottom) @@ -24,12 +25,17 @@ public struct DualCameraScreen: View { errorOverlay(error) } } - .onChange(of: geoProxy.size, initial: true) { oldSize, newSize in - viewModel.containerSizeChanged(newSize) - } .onAppear { + print("initial size", geoProxy.size ) viewModel.onAppear(containerSize: geoProxy.size) } + .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in + print("safeAreaInsets", geoProxy.safeAreaInsets, newSize) + print("frame (global):", geoProxy.frame(in: .global)) + print("frame (local):", geoProxy.frame(in: .local)) + print("frame (named parent):", geoProxy.frame(in: .named("parent"))) + viewModel.containerSizeChanged(newSize) + } .onDisappear { viewModel.onDisappear() } @@ -38,14 +44,14 @@ public struct DualCameraScreen: View { switch sheetType { case .configSheet: DualCameraConfigView( viewModel: viewModel - ) - } + )} }) .alert( item: $viewModel.alert ) { alert in getAlert(for: alert) } + } } diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index 9cf1e53..1cf727a 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -2,6 +2,36 @@ import Observation import Photos import SwiftUI +public enum CaptureScope: Equatable { + case fullScreen + case container + + var displayName: String { + switch self { + case .fullScreen: return "Full Screen" + case .container: return "Container Only" + } + } + + func toPhotoCaptureMode(using size: CGSize) -> DualCameraPhotoCaptureMode { + switch self { + case .fullScreen: + return .fullScreen + case .container: + return .containerSize(size) + } + } +} + +public enum DualCameraRecorderType: String, Equatable, CaseIterable, Identifiable { + case cpuBased = "CPU Recorder" + case replayKit = "ReplayKit" + + public var id: String { rawValue } + + public var displayName: String { rawValue } +} + @MainActor @Observable public final class DualCameraViewModel { @@ -9,9 +39,14 @@ public final class DualCameraViewModel { // Core state private(set) var viewState: CameraViewState = .loading - // Configuration - var configuration: CameraConfiguration - var videoRecorderType: DualCameraVideoRecordingMode { configuration.videoRecorderMode } + var cameraLayout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing) + + // Size tracking + var containerSize: CGSize = .zero + + // Recording configuration + private(set) var selectedRecorderType: DualCameraRecorderType + private(set) var selectedCaptureScope: CaptureScope // User artifacts private(set) var capturedImage: UIImage? = nil @@ -31,27 +66,38 @@ public final class DualCameraViewModel { public init( dualCameraController: DualCameraControlling = CurrentDualCameraEnvironment.dualCameraController, layout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing), - videoRecorderMode: DualCameraVideoRecordingMode = .cpuBased(.init(photoCaptureMode: .fullScreen)), + captureScope: CaptureScope = .fullScreen, + videoRecorderMode: DualCameraRecorderType = .cpuBased, videoSaveStrategy: DualCameraVideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService) ) { self.controller = dualCameraController - self.configuration = CameraConfiguration( - layout: layout, - videoRecorderMode: videoRecorderMode - ) + self.cameraLayout = layout + self.selectedRecorderType = videoRecorderMode + self.selectedCaptureScope = captureScope + self.videoSaveStrategy = videoSaveStrategy self.photoSaveStrategy = photoSaveStrategy } // MARK: - Lifecycle Management + public func onAppear(containerSize: CGSize) { + self.containerSize = containerSize + startSession() + } + 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) @@ -64,11 +110,6 @@ public final class DualCameraViewModel { } } - func onAppear(containerSize: CGSize) { - configuration.containerSize = containerSize - startSession() - } - func onDisappear() { // Clean up if case .recording = viewState { @@ -81,12 +122,12 @@ public final class DualCameraViewModel { // MARK: - Configuration Updates - func containerSizeChanged(_ newSize: CGSize) { - configuration.containerSize = newSize + public func containerSizeChanged(_ newSize: CGSize) { + self.containerSize = newSize } func updateLayout(_ newLayout: DualCameraLayout) { - configuration.layout = newLayout + self.cameraLayout = newLayout } func didTapConfigurationButton() { @@ -99,12 +140,13 @@ public final class DualCameraViewModel { func capturePhotoButtonTapped() { Task { + print("capturePhotoButtonTapped viewSTate", viewState) guard case .ready = viewState else { return } viewState = .capturing do { try await Task.sleep(for: .seconds(0.25)) - let image = try await controller.captureCurrentScreen(mode: configuration.videoRecorderMode.photoCaptureMode) + let image = try await controller.captureCurrentScreen(mode: selectedCaptureScope.toPhotoCaptureMode(using: containerSize)) viewState = .ready try await self.photoSaveStrategy.save(image) self.provideSaveSuccessHapticFeedback() @@ -132,10 +174,10 @@ public final class DualCameraViewModel { } func toggleRecorderType() { - if case .cpuBased = configuration.videoRecorderMode { - configuration.videoRecorderMode = .replayKit() + if case .cpuBased = selectedRecorderType { + selectedRecorderType = .replayKit } else { - configuration.videoRecorderMode = .cpuBased(.init(photoCaptureMode: .fullScreen)) + selectedRecorderType = .cpuBased } } @@ -145,7 +187,7 @@ public final class DualCameraViewModel { Task { viewState = .precapture do { - try await controller.startVideoRecording(mode: configuration.videoRecorderMode) + try await controller.startVideoRecording(mode: effectiveRecorderMode) viewState = .recording(CameraViewState.RecordingState(duration: 0)) @@ -200,6 +242,16 @@ public final class DualCameraViewModel { } } + private var effectiveRecorderMode: DualCameraVideoRecordingMode { + switch selectedRecorderType { + case .cpuBased: + return .cpuBased(.init(photoCaptureMode: selectedCaptureScope.toPhotoCaptureMode(using: containerSize))) + case .replayKit: + // ReplayKit always uses full screen regardless of selected scope + return .replayKit() + } + } + // MARK: - Error Handling private func showError(_ error: Error, message: String) { @@ -230,6 +282,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 59815ae..da69280 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 1301bd3f7558401aed126a565bf24455a1fc5917 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Fri, 23 May 2025 13:20:27 -0700 Subject: [PATCH 5/7] layout tweaks - optionally hide vidBTN, settingsBTN --- .../DualCameraKit/DualCameraController.swift | 5 +-- .../Screen/DualCameraScreen.swift | 42 +++++++++++++------ .../Screen/DualCameraViewModel.swift | 22 ++++------ 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 051c300..82758d1 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/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 4c7a33e..8f5a2b2 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -17,7 +17,7 @@ public struct DualCameraScreen: View { layout: viewModel.cameraLayout ) .ignoresSafeArea() - .overlay(settingsButton, alignment: .topLeading) + .overlay(viewModel.isSettingsButtonVisible ? settingsButton : nil, alignment: .topLeading) .overlay(recordingIndicator, alignment: .top) .overlay(controlButtons, alignment: .bottom) @@ -97,18 +97,20 @@ public struct DualCameraScreen: View { } .disabled(!viewModel.viewState.isPhotoButtonEnabled) - // Video recording button - Button(action: viewModel.recordVideoButtonTapped) { - Image(systemName: viewModel.viewState.videoButtonIcon) - .font(.largeTitle) - .foregroundColor(viewModel.viewState.videoButtonColor) - .padding() - .background( - Circle() - .fill(viewModel.viewState.videoButtonBackgroundColor) - ) + if viewModel.isVideoButtonVisible { + // Video recording button + Button(action: viewModel.recordVideoButtonTapped) { + Image(systemName: viewModel.viewState.videoButtonIcon) + .font(.largeTitle) + .foregroundColor(viewModel.viewState.videoButtonColor) + .padding() + .background( + Circle() + .fill(viewModel.viewState.videoButtonBackgroundColor) + ) + } + .disabled(!viewModel.viewState.isVideoButtonEnabled) } - .disabled(!viewModel.viewState.isVideoButtonEnabled) } } .opacity(viewModel.viewState.captureInProgress ? 0 : 1) @@ -185,6 +187,20 @@ public struct DualCameraScreen: View { // MARK: - Preview -#Preview() { +#Preview("Photo & Video") { DualCameraScreen() } + +#Preview("Photo & Video - Show Settings Button") { + DualCameraScreen(viewModel: .init( + showSettingsButton: false + )) +} + +#Preview("Photo") { + DualCameraScreen(viewModel: .init( + videoSaveStrategy: nil, + showSettingsButton: false + )) +} + diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index 1cf727a..5851ed8 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -59,8 +59,11 @@ public final class DualCameraViewModel { var presentedSheet: SheetType? let controller: DualCameraControlling + var isVideoButtonVisible: Bool { videoSaveStrategy != nil } + var isSettingsButtonVisible: Bool + private var recordingTimer: Timer? - private var videoSaveStrategy: DualCameraVideoSaveStrategy + private var videoSaveStrategy: DualCameraVideoSaveStrategy? private var photoSaveStrategy: DualCameraPhotoSaveStrategy public init( @@ -68,15 +71,15 @@ public final class DualCameraViewModel { 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) - + videoSaveStrategy: DualCameraVideoSaveStrategy? = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + showSettingsButton: Bool = false ) { self.controller = dualCameraController self.cameraLayout = layout self.selectedRecorderType = videoRecorderMode self.selectedCaptureScope = captureScope - + self.isSettingsButtonVisible = showSettingsButton self.videoSaveStrategy = videoSaveStrategy self.photoSaveStrategy = photoSaveStrategy } @@ -89,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) @@ -140,7 +138,6 @@ public final class DualCameraViewModel { func capturePhotoButtonTapped() { Task { - print("capturePhotoButtonTapped viewSTate", viewState) guard case .ready = viewState else { return } viewState = .capturing @@ -227,7 +224,7 @@ public final class DualCameraViewModel { // Reset recording state viewState = .ready - try await self.videoSaveStrategy.save(videoRecordingOutputURL) + try await self.videoSaveStrategy?.save(videoRecordingOutputURL) self.provideSaveSuccessHapticFeedback() } catch let error as DualCameraError { viewState = .error(error) @@ -282,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 bf07717c8b72234cc0f8f4a8d274984a02fbc60a Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 10:55:57 -0700 Subject: [PATCH 6/7] fix stream restarting on return --- Sources/DualCameraKit/DualCameraController.swift | 4 ++++ .../DualCameraKit/DualCameraPhotoCapturing.swift | 1 - .../DualCameraKit/Screen/DualCameraScreen.swift | 16 ++++++++-------- .../Screen/DualCameraViewModel.swift | 3 ++- .../DualCameraCPUVideoRecorderManager.swift | 10 +++++++++- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index 82758d1..8399b8b 100644 --- a/Sources/DualCameraKit/DualCameraController.swift +++ b/Sources/DualCameraKit/DualCameraController.swift @@ -99,6 +99,8 @@ public final class DualCameraController: DualCameraControlling { public func stopSession() { streamSource.stopSession() cancelRendererTasks() + // Clear renderers so they're recreated with fresh stream connections on next startSession() + renderers.removeAll() } /// Creates a renderer (using MetalCameraRenderer by default). @@ -199,6 +201,8 @@ public final class DualCameraMockController: DualCameraControlling { public func stopSession() { streamSource.stopSession() cancelRendererTasks() + // Clear renderers so they're recreated with fresh stream connections on next startSession() + renderers.removeAll() } public var photoCapturer: any DualCameraPhotoCapturing diff --git a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift index 14f8d5f..92b6cd1 100644 --- a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift +++ b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift @@ -41,7 +41,6 @@ public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { /// Returns an image that is a screenshot of the screen. public func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage { let application = UIApplication.shared - print("mode is", mode) guard let keyWindow = application.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive })? diff --git a/Sources/DualCameraKit/Screen/DualCameraScreen.swift b/Sources/DualCameraKit/Screen/DualCameraScreen.swift index 8f5a2b2..10fc63d 100644 --- a/Sources/DualCameraKit/Screen/DualCameraScreen.swift +++ b/Sources/DualCameraKit/Screen/DualCameraScreen.swift @@ -2,11 +2,14 @@ import SwiftUI public struct DualCameraScreen: View { @State private var viewModel: DualCameraViewModel + private let customOverlay: ((DualCameraViewModel) -> AnyView) public init( - viewModel: DualCameraViewModel = .default() + viewModel: DualCameraViewModel = .default(), + @ViewBuilder customOverlay: @escaping (DualCameraViewModel) -> some View = { _ in EmptyView() } ) { _viewModel = State(initialValue: viewModel) + self.customOverlay = { AnyView(customOverlay($0)) } } public var body: some View { @@ -26,14 +29,9 @@ public struct DualCameraScreen: View { } } .onAppear { - print("initial size", geoProxy.size ) viewModel.onAppear(containerSize: geoProxy.size) } .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in - print("safeAreaInsets", geoProxy.safeAreaInsets, newSize) - print("frame (global):", geoProxy.frame(in: .global)) - print("frame (local):", geoProxy.frame(in: .local)) - print("frame (named parent):", geoProxy.frame(in: .named("parent"))) viewModel.containerSizeChanged(newSize) } .onDisappear { @@ -51,7 +49,9 @@ public struct DualCameraScreen: View { ) { alert in getAlert(for: alert) } - + .overlay(alignment: .top) { + customOverlay(viewModel) + } } } @@ -114,7 +114,7 @@ public struct DualCameraScreen: View { } } .opacity(viewModel.viewState.captureInProgress ? 0 : 1) - .padding(.bottom, 30) + //.padding(.bottom, 30) } @ViewBuilder diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index 5851ed8..15535d2 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -38,7 +38,7 @@ public final class DualCameraViewModel { // Core state private(set) var viewState: CameraViewState = .loading - + public var isCameraViewStateCapturing: Bool { viewState.captureInProgress } var cameraLayout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing) // Size tracking @@ -89,6 +89,7 @@ public final class DualCameraViewModel { public func onAppear(containerSize: CGSize) { self.containerSize = containerSize startSession() + print("~~ onAppear DCVM called") } private func startSession() { diff --git a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift index da69280..197b2ba 100644 --- a/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift +++ b/Sources/DualCameraKit/VideoRecording/DualCameraCPUVideoRecorderManager.swift @@ -171,7 +171,15 @@ public actor DualCameraCPUVideoRecorderManager: DualCameraVideoRecording { // Get base dimensions switch mode { case .fullScreen: - rawSize = await MainActor.run { UIScreen.main.bounds.size } + rawSize = await MainActor.run { + let screen = UIScreen.main + let bounds = screen.bounds + let scale = screen.scale + return CGSize( + width: bounds.width * scale, + height: bounds.height * scale + ) + } case .containerSize(let size): rawSize = size } From 0c43348ba3c1b6e6c2c1cb23742669689afb4497 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Thu, 30 Oct 2025 11:00:05 -0700 Subject: [PATCH 7/7] cleanups --- Sources/DualCameraKit/DualCameraDisplayView.swift | 12 +----------- Sources/DualCameraKit/DualCameraPhotoCapturing.swift | 8 -------- .../DualCameraKit/Screen/DualCameraViewModel.swift | 1 - 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/Sources/DualCameraKit/DualCameraDisplayView.swift b/Sources/DualCameraKit/DualCameraDisplayView.swift index 9c04576..46e0d41 100644 --- a/Sources/DualCameraKit/DualCameraDisplayView.swift +++ b/Sources/DualCameraKit/DualCameraDisplayView.swift @@ -27,7 +27,6 @@ public struct DualCameraDisplayView: View { for: (miniCamera == .front ? .back : .front) ) ) -// .ignoresSafeArea(.all) // Mini camera in corner DualCameraRendererView(renderer: controller.getRenderer(for: miniCamera)) @@ -42,24 +41,15 @@ public struct DualCameraDisplayView: View { cameraView(for: .back, widthFraction: 0.5) cameraView(for: .front, widthFraction: 0.5) } -// .ignoresSafeArea(.all) case .stackedVertical: VStack(spacing: 0) { cameraView(for: .back, heightFraction: 0.5) cameraView(for: .front, heightFraction: 0.5) } -// .ignoresSafeArea(.all) } } - .task { - // TODO: fixme - should View here depend on VM? this functionality led to bug in screen due to ctrl.startSession vs. vm.startSession -// do { -// try await controller.startSession() -// } catch { -// print("Camera session error: \(error)") -// } - } + } /// Renders a camera feed in partial or full size diff --git a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift index 92b6cd1..46f6779 100644 --- a/Sources/DualCameraKit/DualCameraPhotoCapturing.swift +++ b/Sources/DualCameraKit/DualCameraPhotoCapturing.swift @@ -89,14 +89,6 @@ public class DualCameraPhotoCapturer: DualCameraPhotoCapturing { let capturedImage = renderer.image { context in let cgContext = context.cgContext - // Calculate scaling -// let scaleX = size.width / fullScreenSize.width -// let scaleY = size.height / fullScreenSize.height -// let scale = min(scaleX, scaleY) -// -// // Apply scaling -// cgContext.scaleBy(x: scale, y: scale) - keyWindow.drawHierarchy( in: CGRect(origin: .zero, size: fullScreenSize), afterScreenUpdates: afterScreenUpdates 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() {