Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-04 16:21:41 -06:00
parent f7c554d018
commit 66736d39f9
6 changed files with 411 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: ", ")
}
}

View File

@ -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

View File

@ -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
}
}