diff --git a/DualCameraDemo/DualCameraDemo/ContainerExample.swift b/DualCameraDemo/DualCameraDemo/ContainerExample.swift new file mode 100644 index 0000000..c7529ae --- /dev/null +++ b/DualCameraDemo/DualCameraDemo/ContainerExample.swift @@ -0,0 +1,100 @@ +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( + captureScope: .container +// photoSaveStrategy: photoSaveStrategy + ) + + var body: some View { + VStack { + Group { + switch selectedTab { + case .feed: + VStack { + Color.mint + } + case .camera: + ZStack { + GeometryReader { proxy in + DualCameraScreen( + viewModel: vm + ) + .onChange(of: proxy.size, initial: true) { _, newSize in + vm.containerSizeChanged(newSize) + } + } + } + case .map: + VStack { + Color.teal + } + } + } + tabBar + .edgesIgnoringSafeArea(.all) + } + } + + @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 { + 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..b341079 100644 --- a/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift +++ b/DualCameraDemo/DualCameraDemo/DualCameraDemoApp.swift @@ -5,22 +5,26 @@ import SwiftUI @main struct DualCameraDemoApp: App { enum DemoDisplayType { - case dualCameraScreen + case dualCameraScreen(isFullScreen: Bool) case dualCameraDisplayView case dualCameraLowLevelComponents } - @State private var demoType = DemoDisplayType.dualCameraScreen + @State private var demoType = DemoDisplayType.dualCameraScreen(isFullScreen: false) var body: some Scene { WindowGroup { switch demoType { - case .dualCameraScreen: - DualCameraScreen() + case .dualCameraScreen(let isFullScreen): + switch isFullScreen { + case true: + DualCameraScreen() + case false: + ContainerExample() + } case .dualCameraDisplayView, .dualCameraLowLevelComponents: Text("Not Implemented Yet") } - } } } 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/DualCameraDisplayView.swift b/Sources/DualCameraKit/DualCameraDisplayView.swift index afe72c5..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,23 +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 { - 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 678ef3f..46f6779 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 - guard let keyWindow = application.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive })? @@ -90,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/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 00a03ae..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 { @@ -14,9 +17,10 @@ public struct DualCameraScreen: View { ZStack { DualCameraDisplayView( controller: viewModel.controller, - layout: viewModel.configuration.layout + layout: viewModel.cameraLayout ) - .overlay(settingsButton, alignment: .topLeading) + .ignoresSafeArea() + .overlay(viewModel.isSettingsButtonVisible ? settingsButton : nil, alignment: .topLeading) .overlay(recordingIndicator, alignment: .top) .overlay(controlButtons, alignment: .bottom) @@ -24,12 +28,12 @@ public struct DualCameraScreen: View { errorOverlay(error) } } - .onChange(of: geoProxy.size, initial: true) { oldSize, newSize in - viewModel.containerSizeChanged(newSize) - } .onAppear { viewModel.onAppear(containerSize: geoProxy.size) } + .onChange(of: geoProxy.size, initial: false) { oldSize, newSize in + viewModel.containerSizeChanged(newSize) + } .onDisappear { viewModel.onDisappear() } @@ -38,14 +42,16 @@ public struct DualCameraScreen: View { switch sheetType { case .configSheet: DualCameraConfigView( viewModel: viewModel - ) - } + )} }) .alert( item: $viewModel.alert ) { alert in getAlert(for: alert) } + .overlay(alignment: .top) { + customOverlay(viewModel) + } } } @@ -91,22 +97,24 @@ 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) - .padding(.bottom, 30) + //.padding(.bottom, 30) } @ViewBuilder @@ -172,12 +180,27 @@ public struct DualCameraScreen: View { .font(.title2) } .tint(.gray) + .opacity(viewModel.viewState.captureInProgress ? 0 : 1) .padding(.leading) } } // 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 bb73d11..f24bb76 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -2,16 +2,51 @@ 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 { // Core state private(set) var viewState: CameraViewState = .loading + public var isCameraViewStateCapturing: Bool { viewState.captureInProgress } + var cameraLayout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing) + + // Size tracking + var containerSize: CGSize = .zero - // Configuration - var configuration: CameraConfiguration - var videoRecorderType: DualCameraVideoRecordingMode { configuration.videoRecorderMode } + // Recording configuration + private(set) var selectedRecorderType: DualCameraRecorderType + private(set) var selectedCaptureScope: CaptureScope // User artifacts private(set) var capturedImage: UIImage? = nil @@ -24,29 +59,38 @@ 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( dualCameraController: DualCameraControlling = CurrentDualCameraEnvironment.dualCameraController, layout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing), - videoRecorderMode: DualCameraVideoRecordingMode = .cpuBased(.init(photoCaptureMode: .fullScreen)), - videoSaveStrategy: DualCameraVideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), - photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService) - + captureScope: CaptureScope = .fullScreen, + videoRecorderMode: DualCameraRecorderType = .cpuBased, + videoSaveStrategy: DualCameraVideoSaveStrategy? = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + showSettingsButton: Bool = false ) { self.controller = dualCameraController - self.configuration = CameraConfiguration( - layout: layout, - videoRecorderMode: videoRecorderMode - ) + self.cameraLayout = layout + self.selectedRecorderType = videoRecorderMode + self.selectedCaptureScope = captureScope + self.isSettingsButtonVisible = showSettingsButton self.videoSaveStrategy = videoSaveStrategy self.photoSaveStrategy = photoSaveStrategy } // MARK: - Lifecycle Management + public func onAppear(containerSize: CGSize) { + self.containerSize = containerSize + startSession() + } + private func startSession() { Task { do { @@ -64,11 +108,6 @@ public final class DualCameraViewModel { } } - func onAppear(containerSize: CGSize) { - configuration.containerSize = containerSize - startSession() - } - func onDisappear() { // Clean up if case .recording = viewState { @@ -81,12 +120,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() { @@ -104,7 +143,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: selectedCaptureScope.toPhotoCaptureMode(using: containerSize)) viewState = .ready try await self.photoSaveStrategy.save(image) self.provideSaveSuccessHapticFeedback() @@ -132,10 +171,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 +184,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)) @@ -185,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) @@ -200,6 +239,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) { 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 } 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()) ) } - }