Remove dead code and add free UX features

REMOVED (dead code):
- CaptureMode enum (photo/video/boomerang) - never used
- selectedCaptureMode property
- CaptureControlling protocol file
- CenterStageControlling protocol
- SettingsViewModel CaptureControlling conformance

ADDED (free features):
- Double-tap to flip camera (front/back)
- Tap to focus (already built into MijickCamera)
- Volume button shutter (hardware shutter capture)
- Haptic feedback on capture, countdown ticks, and camera flip

NEW FILES:
- VolumeButtonObserver.swift - observes hardware volume buttons
- HiddenVolumeView - prevents system volume HUD
This commit is contained in:
Matt Bruce 2026-01-04 15:39:44 -06:00
parent 52eed458f4
commit 5499b10b2a
5 changed files with 137 additions and 96 deletions

View File

@ -43,12 +43,16 @@ struct CustomCameraScreen: MCameraScreen {
// Track camera position for runtime switching // Track camera position for runtime switching
@State private var currentCameraPosition: CameraPosition? @State private var currentCameraPosition: CameraPosition?
// Volume button shutter observer
@StateObject private var volumeObserver = VolumeButtonObserver()
var body: some View { var body: some View {
ZStack { ZStack {
// Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping // Camera preview with gestures
createCameraOutputView() createCameraOutputView()
.ignoresSafeArea() .ignoresSafeArea()
.scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip .scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip
// Pinch to zoom
.gesture( .gesture(
MagnificationGesture() MagnificationGesture()
.updating($magnification) { currentState, gestureState, transaction in .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 // Ring light overlay - covers corners and creates rounded inner edge
// When ring light is off, still show black corners to maintain rounded appearance // When ring light is off, still show black corners to maintain rounded appearance
@ -155,6 +163,10 @@ struct CustomCameraScreen: MCameraScreen {
.ignoresSafeArea() .ignoresSafeArea()
.transition(.opacity) .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.05), value: isShowingScreenFlash)
.animation(.easeInOut(duration: 0.3), value: isCountdownActive) .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) // Center Stage can be set immediately (system-level, not camera-view-level)
applyCenterStage(cameraSettings.isCenterStageEnabled) 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 .onChange(of: cameraSettings.cameraPositionRaw) { _, newRaw in
// Switch camera when position changes in settings // Switch camera when position changes in settings
@ -283,10 +304,15 @@ struct CustomCameraScreen: MCameraScreen {
countdownSeconds = seconds countdownSeconds = seconds
isCountdownActive = true isCountdownActive = true
// Initial haptic for countdown start
triggerHaptic(.light)
Task { @MainActor in Task { @MainActor in
while countdownSeconds > 0 { while countdownSeconds > 0 {
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
countdownSeconds -= 1 countdownSeconds -= 1
// Haptic tick for each countdown second
triggerHaptic(.light)
} }
// Countdown finished, perform capture // Countdown finished, perform capture
@ -297,6 +323,9 @@ struct CustomCameraScreen: MCameraScreen {
/// Performs the actual capture with screen flash if needed /// Performs the actual capture with screen flash if needed
private func performActualCapture() { private func performActualCapture() {
// Haptic feedback for capture
triggerHaptic(.medium)
print("performActualCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)") print("performActualCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)")
if shouldUseCustomScreenFlash { if shouldUseCustomScreenFlash {
// Save original brightness and boost to max // Save original brightness and boost to max
@ -382,4 +411,33 @@ struct CustomCameraScreen: MCameraScreen {
Design.debugLog("Skin smoothing filter removed") 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()
}
} }

View File

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

View File

@ -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 // MARK: - Settings ViewModel
/// Observable settings view model with iCloud sync across all devices. /// 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 // MARK: - Camera Settings
var flashMode: CameraFlashMode { var flashMode: CameraFlashMode {
@ -486,19 +448,3 @@ final class SettingsViewModel: RingLightConfigurable {
ringSize >= Self.minRingSize 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
}
}

View File

@ -42,37 +42,6 @@ protocol CameraControllingAdvanced: AnyObject {
func setZoomFactor(_ factor: CGFloat) 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 /// Combined protocol for full camera functionality
protocol CameraControlling: CameraSessionManaging, PhotoCapturing, CameraPermissionHandling, CameraControllingAdvanced {} protocol CameraControlling: CameraSessionManaging, PhotoCapturing, CameraPermissionHandling, CameraControllingAdvanced {}

View File

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