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:
parent
52eed458f4
commit
5499b10b2a
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
SelfieCam/Features/Camera/Views/VolumeButtonObserver.swift
Normal file
78
SelfieCam/Features/Camera/Views/VolumeButtonObserver.swift
Normal 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) {}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user