From 66736d39f9e98085bca342ad9bc831e344122f95 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 4 Jan 2026 16:21:41 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- AGENTS.md | 1 + AI_Implementation.md | 48 ++++ README.md | 4 + .../Views/CaptureEventInteraction.swift | 263 ++++++++++++++++++ .../Camera/Views/CustomCameraScreen.swift | 50 ++++ .../Protocols/CaptureEventHandling.swift | 45 +++ 6 files changed, 411 insertions(+) create mode 100644 SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift create mode 100644 SelfieCam/Shared/Protocols/CaptureEventHandling.swift diff --git a/AGENTS.md b/AGENTS.md index de5d8ec..e74b56c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -522,6 +522,7 @@ SelfieCam uses the following architectural patterns: | `RingLightConfigurable` | Ring light settings (size, color, opacity) | `SettingsViewModel` | | `CaptureControlling` | Capture actions (timer, flash, shutter) | `SettingsViewModel` | | `PremiumManaging` | Subscription state and purchases | `PremiumManager` | +| `CaptureEventHandling` | Hardware button capture events (Camera Control, Action button) | `CaptureEventManager` | ## Premium Features diff --git a/AI_Implementation.md b/AI_Implementation.md index e78900e..3ad2b78 100644 --- a/AI_Implementation.md +++ b/AI_Implementation.md @@ -75,6 +75,8 @@ Features/ - Post-capture preview with share functionality - Auto-save option to Photo Library - Front flash using screen brightness +- **Camera Control button** (iPhone 16+): Full press captures, light press locks focus/exposure +- **Hardware shutter**: Volume buttons trigger capture via `VolumeButtonObserver` ### 4. Freemium Model - Built with **RevenueCat** for subscription management @@ -98,6 +100,52 @@ Features/ --- +## Camera Control Button Integration + +### Overview + +The app supports the **Camera Control** button on iPhone 16+ via `AVCaptureEventInteraction` (iOS 17.2+). + +### Files Involved + +| File | Purpose | +|------|---------| +| `Shared/Protocols/CaptureEventHandling.swift` | Protocol defining hardware capture event handling | +| `Features/Camera/Views/CaptureEventInteraction.swift` | `AVCaptureEventInteraction` wrapper and SwiftUI integration | +| `Features/Camera/Views/VolumeButtonObserver.swift` | Volume button capture support (legacy) | + +### Supported Hardware Events + +| Event | Hardware | Action | +|-------|----------|--------| +| **Primary (full press)** | Camera Control, Action Button | Capture photo | +| **Secondary (light press)** | Camera Control | Lock focus/exposure | +| **Volume buttons** | All iPhones | Capture photo | + +### Implementation Details + +```swift +// CaptureEventInteractionView is added to the camera ZStack +CaptureEventInteractionView( + onCapture: { performCapture() }, + onFocusLock: { locked in handleFocusLock(locked) } +) + +// The interaction uses AVCaptureEventInteraction (iOS 17.2+) +AVCaptureEventInteraction( + primaryEventHandler: { phase in /* capture on .ended */ }, + secondaryEventHandler: { phase in /* focus lock on .began/.ended */ } +) +``` + +### Device Compatibility + +- **iPhone 16+**: Full Camera Control button support (press + light press) +- **iPhone 15 Pro+**: Action button support (when configured for camera) +- **All iPhones**: Volume button shutter via `VolumeButtonObserver` + +--- + ## Premium Feature Implementation ### How Premium Gating Works diff --git a/README.md b/README.md index bc15201..98d1fb2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Perfect for low-light selfies, content creation, video calls, makeup application - Pinch-to-zoom gesture support - Rule-of-thirds grid overlay (toggleable) - Post-capture preview with share functionality +- **Camera Control button** support (iPhone 16+): Full press to capture, light press to lock focus/exposure +- **Hardware shutter**: Volume buttons trigger capture ### Premium Features (Freemium Model) - **Custom ring light colors** with full color picker @@ -162,6 +164,7 @@ SelfieCam/ │ │ │ ├── RingLightOverlay.swift │ │ │ ├── CaptureButton.swift │ │ │ ├── ExpandableControlsPanel.swift +│ │ │ ├── CaptureEventInteraction.swift # Camera Control button support │ │ │ └── ... │ │ ├── GridOverlay.swift # Rule of thirds overlay │ │ └── PostCapturePreviewView.swift @@ -183,6 +186,7 @@ SelfieCam/ │ ├── Protocols/ # Shared protocols │ │ ├── RingLightConfigurable.swift │ │ ├── CaptureControlling.swift +│ │ ├── CaptureEventHandling.swift # Hardware capture events │ │ └── PremiumManaging.swift │ ├── Premium/ # Subscription management │ │ └── PremiumManager.swift diff --git a/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift b/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift new file mode 100644 index 0000000..80093ab --- /dev/null +++ b/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift @@ -0,0 +1,263 @@ +// +// CaptureEventInteraction.swift +// SelfieCam +// +// Handles hardware capture events using AVCaptureEventInteraction (iOS 17.2+). +// Supports Camera Control button (iPhone 16+), Action button, and volume buttons. +// + +import AVFoundation +import AVKit +import Bedrock +import SwiftUI +import UIKit + +/// Manager for AVCaptureEventInteraction that handles Camera Control button events +/// +/// This class wraps `AVCaptureEventInteraction` to provide hardware button support: +/// - **Camera Control button** (iPhone 16+): Full press to capture, light press for focus/exposure +/// - **Action button** (iPhone 15 Pro+): When configured for camera +/// - **Volume buttons**: Hardware shutter via system integration +/// +/// - Note: Requires iOS 17.2+ for `AVCaptureEventInteraction` API +@Observable @MainActor +final class CaptureEventManager { + private(set) var isActive = false + private(set) var isFocusLocked = false + + // Store as Any to avoid availability issues with the type declaration + private var eventInteraction: AnyObject? + private var onCapture: (() -> Void)? + private var onFocusLock: ((Bool) -> Void)? + + /// Sets up capture event handling with the provided callbacks + /// - Parameters: + /// - onCapture: Called when a capture event is triggered (full press) + /// - onFocusLock: Called when focus/exposure lock state changes (light press) + func configure( + onCapture: @escaping () -> Void, + onFocusLock: ((Bool) -> Void)? = nil + ) { + self.onCapture = onCapture + self.onFocusLock = onFocusLock + } + + /// Creates and returns the UIInteraction to be added to a view + /// - Returns: The configured interaction, or nil if not available + func createInteraction() -> (any UIInteraction)? { + guard #available(iOS 17.2, *) else { + Design.debugLog("CaptureEventInteraction: iOS 17.2+ required") + return nil + } + + return createCaptureEventInteraction() + } + + @available(iOS 17.2, *) + private func createCaptureEventInteraction() -> AVCaptureEventInteraction { + let interaction = AVCaptureEventInteraction( + primary: { [weak self] event in + Task { @MainActor [weak self] in + self?.handlePrimaryEvent(event) + } + }, + secondary: { [weak self] event in + Task { @MainActor [weak self] in + self?.handleSecondaryEvent(event) + } + } + ) + + self.eventInteraction = interaction + self.isActive = true + Design.debugLog("CaptureEventInteraction: Hardware capture events enabled") + + return interaction + } + + /// Removes the interaction and cleans up + func invalidate() { + eventInteraction = nil + isActive = false + isFocusLocked = false + Design.debugLog("CaptureEventInteraction: Hardware capture events disabled") + } + + // MARK: - Event Handlers + + @available(iOS 17.2, *) + private func handlePrimaryEvent(_ event: AVCaptureEvent) { + switch event.phase { + case .began: + Design.debugLog("CaptureEventInteraction: Primary event began (press detected)") + // Haptic feedback on press + triggerHaptic(.light) + + case .ended: + Design.debugLog("CaptureEventInteraction: Primary event ended (capture)") + onCapture?() + + case .cancelled: + Design.debugLog("CaptureEventInteraction: Primary event cancelled") + + @unknown default: + break + } + } + + @available(iOS 17.2, *) + private func handleSecondaryEvent(_ event: AVCaptureEvent) { + switch event.phase { + case .began: + Design.debugLog("CaptureEventInteraction: Secondary event began (focus lock)") + isFocusLocked = true + onFocusLock?(true) + triggerHaptic(.light) + + case .ended: + Design.debugLog("CaptureEventInteraction: Secondary event ended (focus unlock)") + isFocusLocked = false + onFocusLock?(false) + + case .cancelled: + Design.debugLog("CaptureEventInteraction: Secondary event cancelled") + isFocusLocked = false + onFocusLock?(false) + + @unknown default: + break + } + } + + // MARK: - Haptic Feedback + + private func triggerHaptic(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred() + } +} + +// MARK: - SwiftUI View for Hosting the Interaction + +/// A SwiftUI view that hosts AVCaptureEventInteraction for Camera Control button support +/// +/// Add this view to your camera hierarchy to enable hardware capture events. +/// The view is invisible and passes through all touches. +/// +/// Usage: +/// ```swift +/// ZStack { +/// CameraPreview() +/// CaptureEventInteractionView( +/// onCapture: { performCapture() }, +/// onFocusLock: { locked in handleFocusLock(locked) } +/// ) +/// } +/// ``` +struct CaptureEventInteractionView: UIViewRepresentable { + /// Called when primary capture event occurs (full press) + let onCapture: () -> Void + + /// Called when focus/exposure lock state changes (light press) + var onFocusLock: ((Bool) -> Void)? + + func makeUIView(context: Context) -> CaptureEventHostView { + let view = CaptureEventHostView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = true + + // Configure and add the interaction + context.coordinator.manager.configure( + onCapture: onCapture, + onFocusLock: onFocusLock + ) + + if let interaction = context.coordinator.manager.createInteraction() { + view.addInteraction(interaction) + context.coordinator.interaction = interaction + } + + return view + } + + func updateUIView(_ uiView: CaptureEventHostView, context: Context) { + // Update callbacks if they change + context.coordinator.manager.configure( + onCapture: onCapture, + onFocusLock: onFocusLock + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + static func dismantleUIView(_ uiView: CaptureEventHostView, coordinator: Coordinator) { + if let interaction = coordinator.interaction { + uiView.removeInteraction(interaction) + } + coordinator.manager.invalidate() + } + + @MainActor + class Coordinator { + let manager = CaptureEventManager() + var interaction: (any UIInteraction)? + } +} + +/// UIView subclass that hosts AVCaptureEventInteraction +/// +/// This view is transparent and allows touches to pass through while +/// still receiving hardware capture events. +final class CaptureEventHostView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + backgroundColor = .clear + isUserInteractionEnabled = true + } + + // Allow touches to pass through to underlying views + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Return nil to pass touches through, but the interaction still receives hardware events + return nil + } +} + +// MARK: - Device Capability Check + +extension CaptureEventManager { + /// Checks if the device supports Camera Control button + static var isCameraControlSupported: Bool { + guard #available(iOS 17.2, *) else { return false } + + // Camera Control is available on iPhone 16 series + // We check for iOS 17.2+ as the API requirement + // The actual button is hardware-dependent + return true + } + + /// Returns a description of supported hardware capture methods + static var supportedHardwareMethods: String { + var methods: [String] = [] + + if #available(iOS 17.2, *) { + methods.append("Camera Control (iPhone 16+)") + methods.append("Action Button") + methods.append("Volume Buttons") + } else { + methods.append("Volume Buttons (legacy)") + } + + return methods.joined(separator: ", ") + } +} diff --git a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift index 28960f3..625fc27 100644 --- a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift +++ b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift @@ -167,6 +167,16 @@ struct CustomCameraScreen: MCameraScreen { // Hidden volume view to prevent system HUD when using volume buttons as shutter HiddenVolumeView() .frame(width: 0, height: 0) + + // Camera Control button and hardware capture event support (iOS 17.2+) + CaptureEventInteractionView( + onCapture: { performCapture() }, + onFocusLock: { locked in + handleFocusLock(locked) + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } .animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash) .animation(.easeInOut(duration: 0.3), value: isCountdownActive) @@ -412,6 +422,46 @@ struct CustomCameraScreen: MCameraScreen { } } + // MARK: - Focus/Exposure Lock (Camera Control Light Press) + + /// Handles focus/exposure lock from Camera Control button light press + /// - Parameter locked: Whether focus/exposure is being locked + private func handleFocusLock(_ locked: Bool) { + guard let device = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: cameraPosition == .front ? .front : .back + ) else { return } + + do { + try device.lockForConfiguration() + + if locked { + // Lock current focus and exposure + if device.isFocusModeSupported(.locked) { + device.focusMode = .locked + } + if device.isExposureModeSupported(.locked) { + device.exposureMode = .locked + } + Design.debugLog("Focus/Exposure locked via Camera Control") + } else { + // Restore continuous autofocus and auto exposure + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + Design.debugLog("Focus/Exposure unlocked via Camera Control") + } + + device.unlockForConfiguration() + } catch { + Design.debugLog("Failed to set focus/exposure lock: \(error)") + } + } + // MARK: - Camera Gestures /// Flips between front and back camera with haptic feedback diff --git a/SelfieCam/Shared/Protocols/CaptureEventHandling.swift b/SelfieCam/Shared/Protocols/CaptureEventHandling.swift new file mode 100644 index 0000000..c8cd879 --- /dev/null +++ b/SelfieCam/Shared/Protocols/CaptureEventHandling.swift @@ -0,0 +1,45 @@ +// +// CaptureEventHandling.swift +// SelfieCam +// +// Protocol for handling hardware capture events (Camera Control button, Action button, volume buttons). +// + +import Foundation + +/// Protocol defining hardware capture event handling capabilities +/// +/// Conforming types can respond to hardware button events for camera capture, +/// including the Camera Control button on iPhone 16+, Action button on iPhone 15 Pro+, +/// and volume buttons. +/// +/// - Note: This protocol is designed to work with `AVCaptureEventInteraction` (iOS 17.2+) +protocol CaptureEventHandling: AnyObject { + /// Called when a primary capture event occurs (full press on Camera Control, volume button press) + /// - Parameter phase: The phase of the event (.began, .ended, .cancelled) + func handlePrimaryCaptureEvent(phase: CaptureEventPhase) + + /// Called when a secondary capture event occurs (light press on Camera Control for focus/exposure) + /// - Parameter phase: The phase of the event (.began, .ended, .cancelled) + func handleSecondaryCaptureEvent(phase: CaptureEventPhase) +} + +/// Represents the phase of a hardware capture event +enum CaptureEventPhase: Sendable { + /// Event began (button pressed down or light press detected) + case began + /// Event ended (button released) + case ended + /// Event was cancelled + case cancelled +} + +// MARK: - Default Implementations + +extension CaptureEventHandling { + /// Default implementation for secondary events (focus/exposure lock) + /// Override in conforming types if focus/exposure lock is needed + func handleSecondaryCaptureEvent(phase: CaptureEventPhase) { + // Default: no-op, override if focus/exposure lock support is needed + } +}