SelfieCam/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift
Matt Bruce 1c07009e06 Fix camera position switch using runtime CameraManager.setCameraPosition()
- 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
2026-01-04 15:08:33 -06:00

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()
}
}
}