Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
f7c554d018
commit
66736d39f9
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
263
SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift
Normal file
263
SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift
Normal 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: ", ")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
45
SelfieCam/Shared/Protocols/CaptureEventHandling.swift
Normal file
45
SelfieCam/Shared/Protocols/CaptureEventHandling.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user