- Add onChange handler in CustomCameraScreen to watch cameraPositionRaw - Call cameraManager.setCameraPosition() at runtime when setting changes - Camera now switches without needing to recreate the entire MCamera view - Keep onDismiss fallback in ContentView as safety net
284 lines
12 KiB
Swift
284 lines
12 KiB
Swift
//
|
|
// CustomCameraScreen.swift
|
|
// CameraTester
|
|
//
|
|
// Created by Matt Bruce on 1/2/26.
|
|
//
|
|
|
|
import AVFoundation
|
|
import SwiftUI
|
|
import Bedrock
|
|
import MijickCamera
|
|
|
|
// MARK: - Custom Camera Screen
|
|
|
|
struct CustomCameraScreen: MCameraScreen {
|
|
@ObservedObject var cameraManager: CameraManager
|
|
let namespace: Namespace.ID
|
|
let closeMCameraAction: () -> ()
|
|
|
|
/// Shared camera settings state - using Observable class prevents MCamera recreation
|
|
var cameraSettings: SettingsViewModel
|
|
|
|
/// Callback when photo is captured - bypasses MijickCamera's callback system
|
|
var onPhotoCaptured: ((UIImage) -> Void)?
|
|
|
|
// Center Stage is now managed by settings
|
|
|
|
|
|
|
|
// Screen flash state for front camera
|
|
@State private var isShowingScreenFlash: Bool = false
|
|
@State private var originalBrightness: CGFloat = UIScreen.main.brightness
|
|
|
|
// Timer countdown state
|
|
@State private var countdownSeconds: Int = 0
|
|
@State private var isCountdownActive: Bool = false
|
|
|
|
// Pinch to zoom gesture state
|
|
@GestureState private var magnification: CGFloat = 1.0
|
|
@State private var lastMagnification: CGFloat = 1.0
|
|
|
|
// Track camera position for runtime switching
|
|
@State private var currentCameraPosition: CameraPosition?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping
|
|
createCameraOutputView()
|
|
.ignoresSafeArea()
|
|
.scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip
|
|
.gesture(
|
|
MagnificationGesture()
|
|
.updating($magnification) { currentState, gestureState, transaction in
|
|
gestureState = currentState
|
|
}
|
|
.onEnded { value in
|
|
let newZoom = lastMagnification * value
|
|
lastMagnification = newZoom
|
|
// Clamp to reasonable range
|
|
let clampedZoom = min(max(newZoom, 1.0), 5.0)
|
|
do {
|
|
try setZoomFactor(clampedZoom)
|
|
} catch {
|
|
print("Failed to set zoom factor: \(error)")
|
|
}
|
|
}
|
|
)
|
|
|
|
// Ring light overlay - covers corners and creates rounded inner edge
|
|
// When ring light is off, still show black corners to maintain rounded appearance
|
|
RingLightOverlay(
|
|
color: cameraSettings.isRingLightEnabled ? cameraSettings.lightColor : .black,
|
|
width: cameraSettings.isRingLightEnabled ? cameraSettings.ringSize : Design.CornerRadius.large,
|
|
opacity: cameraSettings.isRingLightEnabled ? cameraSettings.ringLightOpacity : 1.0,
|
|
cornerRadius: Design.CornerRadius.large
|
|
)
|
|
.allowsHitTesting(false) // Allow touches to pass through to camera view
|
|
|
|
// UI overlay - responsive to orientation
|
|
GeometryReader { geometry in
|
|
let isLandscape = geometry.size.width > geometry.size.height
|
|
|
|
if isLandscape {
|
|
// Landscape layout: capture button on left with zoom above
|
|
VStack {
|
|
Spacer()
|
|
CaptureButton(action: { performCapture() })
|
|
Spacer()
|
|
}
|
|
.padding(.leading, Design.Spacing.large)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
} else {
|
|
// Portrait layout: capture button at bottom
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
|
|
// Bottom controls
|
|
VStack(spacing: Design.Spacing.large) {
|
|
// Zoom indicator (shows "Center Stage" when active)
|
|
ZoomControlView(
|
|
zoomFactor: zoomFactor,
|
|
isCenterStageActive: cameraSettings.isCenterStageEnabled
|
|
)
|
|
|
|
// Capture Button with timer indicator badge
|
|
CaptureButton(action: { performCapture() })
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if cameraSettings.selectedTimer.seconds > 0 {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.black.opacity(Design.Opacity.accent))
|
|
.frame(width: 24, height: 24)
|
|
Text(cameraSettings.selectedTimer.displayName)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.white)
|
|
}
|
|
.offset(x: 4, y: 4)
|
|
}
|
|
}
|
|
.padding(.bottom, Design.Spacing.large)
|
|
}
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Countdown overlay
|
|
if isCountdownActive {
|
|
ZStack {
|
|
Color.black.opacity(0.3)
|
|
.ignoresSafeArea()
|
|
|
|
VStack {
|
|
Text("\(countdownSeconds)")
|
|
.font(.system(size: 120, weight: .bold, design: .rounded))
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 10)
|
|
|
|
Text("Get ready!")
|
|
.font(.title2)
|
|
.foregroundColor(.white.opacity(0.8))
|
|
}
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
|
|
// Screen flash overlay for front camera
|
|
if isShowingScreenFlash {
|
|
screenFlashColor
|
|
.ignoresSafeArea()
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash)
|
|
.animation(.easeInOut(duration: 0.3), value: isCountdownActive)
|
|
.onAppear {
|
|
// Set flash mode from saved settings
|
|
setFlashMode(cameraSettings.flashMode.toMijickFlashMode)
|
|
// Tell MijickCamera whether to disable iOS flash (only matters if sync is on)
|
|
updateFlashSyncState()
|
|
// Initialize zoom gesture state
|
|
lastMagnification = zoomFactor
|
|
// Track initial camera position
|
|
currentCameraPosition = cameraSettings.cameraPosition
|
|
}
|
|
.onChange(of: cameraSettings.cameraPositionRaw) { _, newRaw in
|
|
// Switch camera when position changes in settings
|
|
let newPosition: CameraPosition = newRaw == "front" ? .front : .back
|
|
if currentCameraPosition != newPosition {
|
|
Design.debugLog("Camera position changed to \(newPosition) - switching camera...")
|
|
currentCameraPosition = newPosition
|
|
Task {
|
|
do {
|
|
try await cameraManager.setCameraPosition(newPosition)
|
|
Design.debugLog("Camera switched successfully to \(newPosition)")
|
|
} catch {
|
|
Design.debugLog("Failed to switch camera: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: cameraSettings.isFlashSyncedWithRingLight) { _, _ in
|
|
// Only update when sync setting changes, not on every color change
|
|
updateFlashSyncState()
|
|
}
|
|
.onChange(of: cameraManager.capturedMedia) { _, newMedia in
|
|
// Directly observe capture completion - bypasses MijickCamera's callback issues
|
|
if let media = newMedia, let image = media.getImage() {
|
|
print("CustomCameraScreen detected captured media!")
|
|
onPhotoCaptured?(image)
|
|
// Clear the captured media so next capture works
|
|
cameraManager.setCapturedMedia(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Actions
|
|
|
|
private func updateFlashSyncState() {
|
|
// Tell MijickCamera whether we're handling flash ourselves (sync enabled)
|
|
// or if iOS should handle it (sync disabled)
|
|
// We use .white as a placeholder - the actual color comes from ringLightColor in SwiftUI
|
|
if cameraSettings.isFlashSyncedWithRingLight {
|
|
setScreenFlashColor(.white) // Non-nil = we handle flash, disable iOS flash
|
|
} else {
|
|
setScreenFlashColor(nil) // nil = let iOS handle Retina Flash
|
|
}
|
|
}
|
|
|
|
/// The color to use for screen flash overlay
|
|
private var screenFlashColor: Color {
|
|
cameraSettings.isFlashSyncedWithRingLight ? cameraSettings.lightColor : .white
|
|
}
|
|
|
|
/// Whether to use custom screen flash (front camera + flash on + sync enabled)
|
|
private var shouldUseCustomScreenFlash: Bool {
|
|
cameraPosition == .front && cameraSettings.flashMode != .off && cameraSettings.isFlashSyncedWithRingLight
|
|
}
|
|
|
|
/// Performs capture with timer countdown and screen flash if needed
|
|
private func performCapture() {
|
|
// Check if timer is enabled
|
|
let timerSeconds = cameraSettings.selectedTimer.seconds
|
|
|
|
if timerSeconds > 0 && !isCountdownActive {
|
|
// Start countdown
|
|
startCountdown(seconds: timerSeconds)
|
|
} else if !isCountdownActive {
|
|
// No timer or countdown already running, proceed with capture
|
|
performActualCapture()
|
|
}
|
|
}
|
|
|
|
/// Starts the countdown timer
|
|
private func startCountdown(seconds: Int) {
|
|
countdownSeconds = seconds
|
|
isCountdownActive = true
|
|
|
|
Task { @MainActor in
|
|
while countdownSeconds > 0 {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
countdownSeconds -= 1
|
|
}
|
|
|
|
// Countdown finished, perform capture
|
|
isCountdownActive = false
|
|
performActualCapture()
|
|
}
|
|
}
|
|
|
|
/// Performs the actual capture with screen flash if needed
|
|
private func performActualCapture() {
|
|
print("performActualCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)")
|
|
if shouldUseCustomScreenFlash {
|
|
// Save original brightness and boost to max
|
|
originalBrightness = UIScreen.main.brightness
|
|
UIScreen.main.brightness = 1.0
|
|
|
|
// Show flash overlay
|
|
isShowingScreenFlash = true
|
|
|
|
// Wait for camera to adjust to bright screen, then capture
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
print("Calling captureOutput() with custom flash")
|
|
captureOutput()
|
|
|
|
// Keep flash visible briefly after capture
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
isShowingScreenFlash = false
|
|
UIScreen.main.brightness = originalBrightness
|
|
}
|
|
} else {
|
|
// Normal capture (iOS handles Retina Flash for front camera if needed)
|
|
print("Calling captureOutput() without custom flash")
|
|
captureOutput()
|
|
}
|
|
}
|
|
}
|