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
@ -42,13 +42,17 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
|
||||
// Track camera position for runtime switching
|
||||
@State private var currentCameraPosition: CameraPosition?
|
||||
|
||||
// Volume button shutter observer
|
||||
@StateObject private var volumeObserver = VolumeButtonObserver()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping
|
||||
// Camera preview with gestures
|
||||
createCameraOutputView()
|
||||
.ignoresSafeArea()
|
||||
.scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip
|
||||
// Pinch to zoom
|
||||
.gesture(
|
||||
MagnificationGesture()
|
||||
.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
|
||||
// When ring light is off, still show black corners to maintain rounded appearance
|
||||
@ -155,6 +163,10 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
.ignoresSafeArea()
|
||||
.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.3), value: isCountdownActive)
|
||||
@ -191,6 +203,15 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
}
|
||||
// Center Stage can be set immediately (system-level, not camera-view-level)
|
||||
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
|
||||
// Switch camera when position changes in settings
|
||||
@ -282,11 +303,16 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
private func startCountdown(seconds: Int) {
|
||||
countdownSeconds = seconds
|
||||
isCountdownActive = true
|
||||
|
||||
// Initial haptic for countdown start
|
||||
triggerHaptic(.light)
|
||||
|
||||
Task { @MainActor in
|
||||
while countdownSeconds > 0 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
countdownSeconds -= 1
|
||||
// Haptic tick for each countdown second
|
||||
triggerHaptic(.light)
|
||||
}
|
||||
|
||||
// Countdown finished, perform capture
|
||||
@ -297,6 +323,9 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
|
||||
/// Performs the actual capture with screen flash if needed
|
||||
private func performActualCapture() {
|
||||
// Haptic feedback for capture
|
||||
triggerHaptic(.medium)
|
||||
|
||||
print("performActualCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)")
|
||||
if shouldUseCustomScreenFlash {
|
||||
// Save original brightness and boost to max
|
||||
@ -382,4 +411,33 @@ struct CustomCameraScreen: MCameraScreen {
|
||||
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
|
||||
|
||||
/// 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
|
||||
|
||||
var flashMode: CameraFlashMode {
|
||||
@ -486,19 +448,3 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
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