diff --git a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift index 783b2d8..28960f3 100644 --- a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift +++ b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift @@ -42,13 +42,17 @@ struct CustomCameraScreen: MCameraScreen { // Track camera position for runtime switching @State private var currentCameraPosition: CameraPosition? + + // Volume button shutter observer + @StateObject private var volumeObserver = VolumeButtonObserver() var body: some View { ZStack { - // Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping + // Camera preview with gestures createCameraOutputView() .ignoresSafeArea() .scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip + // Pinch to zoom .gesture( MagnificationGesture() .updating($magnification) { currentState, gestureState, transaction in @@ -68,6 +72,10 @@ struct CustomCameraScreen: MCameraScreen { } } ) + // Double-tap to flip camera (single tap focus is handled by MijickCamera) + .onTapGesture(count: 2) { + flipCamera() + } // Ring light overlay - covers corners and creates rounded inner edge // When ring light is off, still show black corners to maintain rounded appearance @@ -155,6 +163,10 @@ struct CustomCameraScreen: MCameraScreen { .ignoresSafeArea() .transition(.opacity) } + + // Hidden volume view to prevent system HUD when using volume buttons as shutter + HiddenVolumeView() + .frame(width: 0, height: 0) } .animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash) .animation(.easeInOut(duration: 0.3), value: isCountdownActive) @@ -191,6 +203,15 @@ struct CustomCameraScreen: MCameraScreen { } // Center Stage can be set immediately (system-level, not camera-view-level) applyCenterStage(cameraSettings.isCenterStageEnabled) + + // Start volume button observer for hardware shutter + volumeObserver.startObserving { + self.performCapture() + } + } + .onDisappear { + // Stop volume button observer + volumeObserver.stopObserving() } .onChange(of: cameraSettings.cameraPositionRaw) { _, newRaw in // Switch camera when position changes in settings @@ -282,11 +303,16 @@ struct CustomCameraScreen: MCameraScreen { private func startCountdown(seconds: Int) { countdownSeconds = seconds isCountdownActive = true + + // Initial haptic for countdown start + triggerHaptic(.light) Task { @MainActor in while countdownSeconds > 0 { try? await Task.sleep(for: .seconds(1)) countdownSeconds -= 1 + // Haptic tick for each countdown second + triggerHaptic(.light) } // Countdown finished, perform capture @@ -297,6 +323,9 @@ struct CustomCameraScreen: MCameraScreen { /// Performs the actual capture with screen flash if needed private func performActualCapture() { + // Haptic feedback for capture + triggerHaptic(.medium) + print("performActualCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)") if shouldUseCustomScreenFlash { // Save original brightness and boost to max @@ -382,4 +411,33 @@ struct CustomCameraScreen: MCameraScreen { Design.debugLog("Skin smoothing filter removed") } } + + // MARK: - Camera Gestures + + /// Flips between front and back camera with haptic feedback + private func flipCamera() { + triggerHaptic(.medium) + + let newPosition: CameraPosition = cameraPosition == .front ? .back : .front + Design.debugLog("Double-tap: flipping camera to \(newPosition)") + + Task { + do { + try await setCameraPosition(newPosition) + // Update settings to persist the change + cameraSettings.cameraPosition = newPosition + currentCameraPosition = newPosition + } catch { + Design.debugLog("Failed to flip camera: \(error)") + } + } + } + + // MARK: - Haptic Feedback + + /// Triggers haptic feedback with specified style + private func triggerHaptic(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred() + } } diff --git a/SelfieCam/Features/Camera/Views/VolumeButtonObserver.swift b/SelfieCam/Features/Camera/Views/VolumeButtonObserver.swift new file mode 100644 index 0000000..dd575cc --- /dev/null +++ b/SelfieCam/Features/Camera/Views/VolumeButtonObserver.swift @@ -0,0 +1,78 @@ +// +// VolumeButtonObserver.swift +// SelfieCam +// +// Observes hardware volume button presses and triggers a callback. +// Used to enable shutter capture via volume buttons. +// + +import AVFoundation +import Combine +import MediaPlayer +import SwiftUI + +/// Observes volume button presses and calls the provided action. +/// Hides the system volume HUD while observing. +@MainActor +final class VolumeButtonObserver: ObservableObject { + @Published private(set) var isObserving = false + + private var volumeObservation: NSKeyValueObservation? + private var onVolumeButtonPress: (@MainActor () -> Void)? + private var lastVolume: Float = 0.5 + + /// Starts observing volume button presses + /// - Parameter action: Closure called when volume button is pressed + func startObserving(action: @escaping @MainActor () -> Void) { + guard !isObserving else { return } + + self.onVolumeButtonPress = action + self.isObserving = true + + let audioSession = AVAudioSession.sharedInstance() + + do { + try audioSession.setActive(true) + lastVolume = audioSession.outputVolume + } catch { + print("Failed to activate audio session: \(error)") + return + } + + // Observe volume changes using KVO + volumeObservation = audioSession.observe(\.outputVolume, options: [.new, .old]) { [weak self] _, change in + guard let newVolume = change.newValue, + let oldVolume = change.oldValue, + newVolume != oldVolume else { return } + + // Trigger capture on volume button press (either up or down) + Task { @MainActor [weak self] in + self?.onVolumeButtonPress?() + } + } + } + + /// Stops observing volume button presses + func stopObserving() { + volumeObservation?.invalidate() + volumeObservation = nil + onVolumeButtonPress = nil + isObserving = false + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } +} + +// MARK: - Hidden Volume View + +/// A hidden MPVolumeView that prevents the system volume HUD from appearing +struct HiddenVolumeView: UIViewRepresentable { + func makeUIView(context: Context) -> MPVolumeView { + let volumeView = MPVolumeView(frame: .zero) + volumeView.alpha = 0.0001 // Nearly invisible but still functional + volumeView.isUserInteractionEnabled = false + return volumeView + } + + func updateUIView(_ uiView: MPVolumeView, context: Context) {} +} diff --git a/SelfieCam/Features/Settings/SettingsViewModel.swift b/SelfieCam/Features/Settings/SettingsViewModel.swift index b400689..deb476f 100644 --- a/SelfieCam/Features/Settings/SettingsViewModel.swift +++ b/SelfieCam/Features/Settings/SettingsViewModel.swift @@ -31,39 +31,6 @@ enum TimerOption: String, CaseIterable, Identifiable { } } -// MARK: - Capture Mode - -enum CaptureMode: String, CaseIterable, Identifiable { - case photo = "photo" - case video = "video" - case boomerang = "boomerang" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .photo: return String(localized: "Photo") - case .video: return String(localized: "Video") - case .boomerang: return String(localized: "Boomerang") - } - } - - var systemImage: String { - switch self { - case .photo: return "camera.fill" - case .video: return "video.fill" - case .boomerang: return "arrow.2.squarepath" - } - } - - var isPremium: Bool { - switch self { - case .photo: return false - case .video, .boomerang: return true - } - } -} - // MARK: - Settings ViewModel /// Observable settings view model with iCloud sync across all devices. @@ -234,11 +201,6 @@ final class SettingsViewModel: RingLightConfigurable { } } - var selectedCaptureMode: CaptureMode { - get { CaptureMode(rawValue: cloudSync.data.selectedCaptureModeRaw) ?? .photo } - set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } } - } - // MARK: - Camera Settings var flashMode: CameraFlashMode { @@ -486,19 +448,3 @@ final class SettingsViewModel: RingLightConfigurable { ringSize >= Self.minRingSize } } - -// MARK: - CaptureControlling Conformance - -extension SettingsViewModel: CaptureControlling { - func startCountdown() async { - // Countdown handled by CameraViewModel - } - - func performCapture() { - // Capture handled by CameraViewModel - } - - func performFlashBurst() async { - // Flash handled by CameraViewModel - } -} diff --git a/SelfieCam/Shared/Protocols/CameraProtocols.swift b/SelfieCam/Shared/Protocols/CameraProtocols.swift index f431d85..7c6da66 100644 --- a/SelfieCam/Shared/Protocols/CameraProtocols.swift +++ b/SelfieCam/Shared/Protocols/CameraProtocols.swift @@ -42,37 +42,6 @@ protocol CameraControllingAdvanced: AnyObject { func setZoomFactor(_ factor: CGFloat) } -/// Protocol defining Center Stage control capabilities -/// Center Stage automatically keeps users centered in the frame using the front camera -protocol CenterStageControlling { - /// Whether Center Stage is currently available on this device - var isCenterStageAvailable: Bool { get } - - /// Whether Center Stage is currently enabled - var isCenterStageEnabled: Bool { get } - - /// Enable or disable Center Stage - /// - Parameter enabled: Whether to enable Center Stage - /// - Throws: If Center Stage cannot be configured - func setCenterStageEnabled(_ enabled: Bool) throws -} - -// MARK: - Center Stage Default Implementation - -extension CenterStageControlling { - var isCenterStageAvailable: Bool { - AVCaptureDevice.isCenterStageEnabled || canEnableCenterStage - } - - private var canEnableCenterStage: Bool { - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { - return false - } - // Check if the device supports center stage by checking active format - return device.activeFormat.isCenterStageSupported - } -} - /// Combined protocol for full camera functionality protocol CameraControlling: CameraSessionManaging, PhotoCapturing, CameraPermissionHandling, CameraControllingAdvanced {} diff --git a/SelfieCam/Shared/Protocols/CaptureControlling.swift b/SelfieCam/Shared/Protocols/CaptureControlling.swift deleted file mode 100644 index 8ca8774..0000000 --- a/SelfieCam/Shared/Protocols/CaptureControlling.swift +++ /dev/null @@ -1,10 +0,0 @@ -protocol CaptureControlling { - var selectedTimer: TimerOption { get set } - var isGridVisible: Bool { get set } - var currentZoomFactor: Double { get set } - var selectedCaptureMode: CaptureMode { get set } - - func startCountdown() async - func performCapture() - func performFlashBurst() async -}