Revert to custom camera implementation - MijickCamera not working
MijickCamera wasn't rendering the camera preview properly in our view hierarchy. Reverting to our custom AVFoundation-based camera implementation which was working correctly. Reverted: - Restored CameraViewModel.swift - Restored CameraPreview.swift - Restored ContentView.swift to pre-MijickCamera version - Removed MijickCamera package dependency Kept: - Open Source Licenses section (now just shows RevenueCat) - All other features and fixes Our custom camera code handles: - Camera preview with proper orientation - Photo/video capture - Front flash effect - Center Stage support - Manual rotation - Zoom gestures
This commit is contained in:
parent
e628897bff
commit
6e1ce6d262
@ -10,7 +10,6 @@
|
|||||||
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
|
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
|
||||||
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
|
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
|
||||||
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
|
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
|
||||||
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F0F2F08600000DC03E1 /* MijickCamera */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -64,7 +63,6 @@
|
|||||||
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
|
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
|
||||||
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
|
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
|
||||||
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
|
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
|
||||||
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -138,7 +136,6 @@
|
|||||||
EA766C852F08306200DC03E1 /* RevenueCat */,
|
EA766C852F08306200DC03E1 /* RevenueCat */,
|
||||||
EA766C872F08306200DC03E1 /* RevenueCatUI */,
|
EA766C872F08306200DC03E1 /* RevenueCatUI */,
|
||||||
EA766F012F08500000DC03E1 /* Bedrock */,
|
EA766F012F08500000DC03E1 /* Bedrock */,
|
||||||
EA766F0F2F08600000DC03E1 /* MijickCamera */,
|
|
||||||
);
|
);
|
||||||
productName = SelfieRingLight;
|
productName = SelfieRingLight;
|
||||||
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
|
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
|
||||||
@ -225,7 +222,6 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
|
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
|
||||||
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
|
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
|
||||||
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
|
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
|
||||||
@ -642,14 +638,6 @@
|
|||||||
kind = branch;
|
kind = branch;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/Mijick/Camera";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 3.0.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
|||||||
233
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal file
233
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct CameraPreview: UIViewRepresentable {
|
||||||
|
let viewModel: CameraViewModel
|
||||||
|
|
||||||
|
// These properties trigger view updates when they change
|
||||||
|
var isMirrorFlipped: Bool
|
||||||
|
var zoomFactor: Double
|
||||||
|
var manualRotationAngle: CGFloat
|
||||||
|
var ringLightColor: Color
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double, manualRotationAngle: CGFloat = 0, ringLightColor: Color = .white) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.isMirrorFlipped = isMirrorFlipped
|
||||||
|
self.zoomFactor = zoomFactor
|
||||||
|
self.manualRotationAngle = manualRotationAngle
|
||||||
|
self.ringLightColor = ringLightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> CameraPreviewUIView {
|
||||||
|
let view = CameraPreviewUIView(viewModel: viewModel)
|
||||||
|
view.contentMode = .scaleAspectFill
|
||||||
|
view.clipsToBounds = true
|
||||||
|
|
||||||
|
// Add pinch-to-zoom gesture
|
||||||
|
let pinch = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:)))
|
||||||
|
view.addGestureRecognizer(pinch)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
|
||||||
|
// Update background color to match ring light
|
||||||
|
uiView.backgroundColor = UIColor(ringLightColor)
|
||||||
|
|
||||||
|
// Update manual rotation
|
||||||
|
uiView.manualRotationAngle = manualRotationAngle
|
||||||
|
|
||||||
|
// Force layout update
|
||||||
|
uiView.setNeedsLayout()
|
||||||
|
uiView.layoutIfNeeded()
|
||||||
|
|
||||||
|
// Apply mirror transform based on settings
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
|
||||||
|
if isMirrorFlipped {
|
||||||
|
uiView.previewLayer?.transform = CATransform3DMakeScale(-1, 1, 1)
|
||||||
|
} else {
|
||||||
|
uiView.previewLayer?.transform = CATransform3DIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
CATransaction.commit()
|
||||||
|
|
||||||
|
// Apply zoom if changed
|
||||||
|
context.coordinator.applyZoom(zoomFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject {
|
||||||
|
let viewModel: CameraViewModel
|
||||||
|
private var lastAppliedZoom: Double = 1.0
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
|
||||||
|
guard gesture.state == .changed else { return }
|
||||||
|
|
||||||
|
let newZoom = max(1.0, min(5.0, viewModel.settings.currentZoomFactor * gesture.scale))
|
||||||
|
viewModel.settings.currentZoomFactor = newZoom
|
||||||
|
gesture.scale = 1.0
|
||||||
|
|
||||||
|
applyZoom(newZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyZoom(_ zoom: Double) {
|
||||||
|
guard zoom != lastAppliedZoom else { return }
|
||||||
|
lastAppliedZoom = zoom
|
||||||
|
|
||||||
|
if let device = viewModel.captureSession?.inputs.first.flatMap({ ($0 as? AVCaptureDeviceInput)?.device }) {
|
||||||
|
do {
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
device.videoZoomFactor = max(1.0, min(zoom, device.activeFormat.videoMaxZoomFactor))
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
} catch {
|
||||||
|
print("Error setting zoom: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIView subclass for camera preview
|
||||||
|
|
||||||
|
class CameraPreviewUIView: UIView {
|
||||||
|
private weak var viewModel: CameraViewModel?
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
/// Manual rotation offset from user (0, 90, 180, 270)
|
||||||
|
var manualRotationAngle: CGFloat = 0
|
||||||
|
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
AVCaptureVideoPreviewLayer.self
|
||||||
|
}
|
||||||
|
|
||||||
|
init(viewModel: CameraViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .black
|
||||||
|
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
setupPreviewLayer()
|
||||||
|
|
||||||
|
// Listen for orientation changes
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleOrientationChange),
|
||||||
|
name: UIDevice.orientationDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPreviewLayer() {
|
||||||
|
guard let viewModel = viewModel,
|
||||||
|
let session = viewModel.captureSession else { return }
|
||||||
|
|
||||||
|
if let layer = self.layer as? AVCaptureVideoPreviewLayer {
|
||||||
|
layer.session = session
|
||||||
|
// Use .resizeAspect to show exactly what will be captured
|
||||||
|
// The ring light fills any letterbox areas naturally
|
||||||
|
layer.videoGravity = .resizeAspect
|
||||||
|
previewLayer = layer
|
||||||
|
viewModel.previewLayer = layer
|
||||||
|
|
||||||
|
// Set initial orientation
|
||||||
|
updatePreviewOrientation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
// Ensure the preview layer fills the entire view bounds
|
||||||
|
previewLayer?.frame = bounds
|
||||||
|
|
||||||
|
// Setup layer if not already done (can happen if session was nil at init)
|
||||||
|
if previewLayer == nil {
|
||||||
|
setupPreviewLayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update orientation on layout changes
|
||||||
|
updatePreviewOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the last valid orientation for fallback
|
||||||
|
private var lastValidOrientation: UIDeviceOrientation = .portrait
|
||||||
|
|
||||||
|
@objc private func handleOrientationChange() {
|
||||||
|
updatePreviewOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePreviewOrientation() {
|
||||||
|
guard let connection = previewLayer?.connection else { return }
|
||||||
|
|
||||||
|
// Get rotation angle based on device orientation
|
||||||
|
var deviceOrientation = UIDevice.current.orientation
|
||||||
|
|
||||||
|
// If device orientation is flat or unknown, try to get from interface orientation
|
||||||
|
if !deviceOrientation.isValidInterfaceOrientation {
|
||||||
|
// Use last known good orientation, or get from window scene
|
||||||
|
if let windowScene = window?.windowScene {
|
||||||
|
switch windowScene.interfaceOrientation {
|
||||||
|
case .portrait:
|
||||||
|
deviceOrientation = .portrait
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
deviceOrientation = .portraitUpsideDown
|
||||||
|
case .landscapeLeft:
|
||||||
|
deviceOrientation = .landscapeRight // Interface and device are inverted
|
||||||
|
case .landscapeRight:
|
||||||
|
deviceOrientation = .landscapeLeft // Interface and device are inverted
|
||||||
|
case .unknown:
|
||||||
|
deviceOrientation = lastValidOrientation
|
||||||
|
@unknown default:
|
||||||
|
deviceOrientation = lastValidOrientation
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deviceOrientation = lastValidOrientation
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Store this as the last valid orientation
|
||||||
|
lastValidOrientation = deviceOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate base rotation angle (in degrees) for the preview layer
|
||||||
|
// For front camera in portrait: sensor is landscape, so rotate 90°
|
||||||
|
let baseRotationAngle: CGFloat
|
||||||
|
switch deviceOrientation {
|
||||||
|
case .portrait:
|
||||||
|
baseRotationAngle = 90
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
baseRotationAngle = 270
|
||||||
|
case .landscapeLeft:
|
||||||
|
baseRotationAngle = 180
|
||||||
|
case .landscapeRight:
|
||||||
|
baseRotationAngle = 0
|
||||||
|
default:
|
||||||
|
baseRotationAngle = 90 // Default to portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add manual rotation offset and normalize to 0-360
|
||||||
|
let totalRotation = (baseRotationAngle + manualRotationAngle).truncatingRemainder(dividingBy: 360)
|
||||||
|
let finalRotation = totalRotation < 0 ? totalRotation + 360 : totalRotation
|
||||||
|
|
||||||
|
// Use modern rotation angle API (iOS 17+)
|
||||||
|
if connection.isVideoRotationAngleSupported(finalRotation) {
|
||||||
|
connection.videoRotationAngle = finalRotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
393
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal file
393
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
import CoreImage
|
||||||
|
import UIKit
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
class CameraViewModel: NSObject {
|
||||||
|
var isCameraAuthorized = false
|
||||||
|
var isPhotoLibraryAuthorized = false
|
||||||
|
var captureSession: AVCaptureSession?
|
||||||
|
var photoOutput: AVCapturePhotoOutput?
|
||||||
|
var videoOutput: AVCaptureMovieFileOutput?
|
||||||
|
var videoDataOutput: AVCaptureVideoDataOutput?
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
var isUsingFrontCamera = true
|
||||||
|
var isRecording = false
|
||||||
|
var originalBrightness: CGFloat = 0.5
|
||||||
|
var ciContext = CIContext()
|
||||||
|
|
||||||
|
/// Whether the preview should be hidden (for front flash effect)
|
||||||
|
var isPreviewHidden = false
|
||||||
|
|
||||||
|
/// Captured media for preview (nil when no capture pending)
|
||||||
|
var capturedMedia: CapturedMedia?
|
||||||
|
|
||||||
|
/// Whether to show the post-capture preview
|
||||||
|
var showPostCapturePreview = false
|
||||||
|
|
||||||
|
/// Toast message to display
|
||||||
|
var toastMessage: String?
|
||||||
|
|
||||||
|
/// Whether Center Stage is available on this device
|
||||||
|
var isCenterStageAvailable = false
|
||||||
|
|
||||||
|
/// Whether Center Stage is currently enabled
|
||||||
|
var isCenterStageEnabled = false
|
||||||
|
|
||||||
|
/// Manual rotation offset (0, 90, 180, 270 degrees)
|
||||||
|
/// Allows user to rotate preview independent of device orientation
|
||||||
|
var manualRotationAngle: CGFloat = 0
|
||||||
|
|
||||||
|
let settings = SettingsViewModel() // Shared config
|
||||||
|
|
||||||
|
// MARK: - Manual Rotation
|
||||||
|
|
||||||
|
/// Cycles through rotation angles: 0 → 90 → 180 → 270 → 0
|
||||||
|
func cycleManualRotation() {
|
||||||
|
manualRotationAngle = (manualRotationAngle + 90).truncatingRemainder(dividingBy: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets manual rotation to match device orientation
|
||||||
|
func resetManualRotation() {
|
||||||
|
manualRotationAngle = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Screen Brightness Handling
|
||||||
|
|
||||||
|
/// Gets the current screen from any available window scene
|
||||||
|
private var currentScreen: UIScreen? {
|
||||||
|
UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.first?.screen
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCurrentBrightness() {
|
||||||
|
if let screen = currentScreen {
|
||||||
|
originalBrightness = screen.brightness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBrightness(_ value: CGFloat) {
|
||||||
|
currentScreen?.brightness = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCamera() async {
|
||||||
|
isCameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
isPhotoLibraryAuthorized = await PHPhotoLibrary.requestAuthorization(for: .addOnly) == .authorized
|
||||||
|
|
||||||
|
guard isCameraAuthorized else { return }
|
||||||
|
|
||||||
|
captureSession = AVCaptureSession()
|
||||||
|
guard let session = captureSession else { return }
|
||||||
|
|
||||||
|
session.beginConfiguration()
|
||||||
|
// Use .photo preset for optimal photo quality and consistent 4:3 aspect ratio
|
||||||
|
session.sessionPreset = .photo
|
||||||
|
|
||||||
|
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isUsingFrontCamera ? .front : .back)
|
||||||
|
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||||
|
if session.canAddInput(input) {
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
photoOutput = AVCapturePhotoOutput()
|
||||||
|
if let photoOutput, session.canAddOutput(photoOutput) {
|
||||||
|
session.addOutput(photoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoOutput = AVCaptureMovieFileOutput()
|
||||||
|
if let videoOutput, session.canAddOutput(videoOutput) {
|
||||||
|
session.addOutput(videoOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoDataOutput = AVCaptureVideoDataOutput()
|
||||||
|
videoDataOutput?.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
|
||||||
|
if let videoDataOutput, session.canAddOutput(videoDataOutput) {
|
||||||
|
session.addOutput(videoDataOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.commitConfiguration()
|
||||||
|
session.startRunning()
|
||||||
|
|
||||||
|
// Check Center Stage availability
|
||||||
|
updateCenterStageAvailability()
|
||||||
|
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
saveCurrentBrightness()
|
||||||
|
// Set screen to full brightness for best ring light effect
|
||||||
|
setBrightness(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Center Stage
|
||||||
|
|
||||||
|
/// Updates Center Stage availability based on current camera
|
||||||
|
private func updateCenterStageAvailability() {
|
||||||
|
isCenterStageAvailable = AVCaptureDevice.isCenterStageEnabled || checkCenterStageSupport()
|
||||||
|
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the current device supports Center Stage
|
||||||
|
private func checkCenterStageSupport() -> Bool {
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Center Stage is available if the device has it as an active format feature
|
||||||
|
return device.activeFormat.isCenterStageSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles Center Stage on/off
|
||||||
|
func toggleCenterStage() {
|
||||||
|
guard isCenterStageAvailable else { return }
|
||||||
|
|
||||||
|
AVCaptureDevice.centerStageControlMode = .app
|
||||||
|
AVCaptureDevice.isCenterStageEnabled.toggle()
|
||||||
|
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Orientation
|
||||||
|
|
||||||
|
/// Updates video orientation based on device orientation
|
||||||
|
func updateVideoOrientation(for orientation: UIDeviceOrientation) {
|
||||||
|
guard let connection = photoOutput?.connection(with: .video) else { return }
|
||||||
|
|
||||||
|
// Calculate rotation angle (in degrees)
|
||||||
|
let rotationAngle: CGFloat
|
||||||
|
switch orientation {
|
||||||
|
case .portrait:
|
||||||
|
rotationAngle = 90
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
rotationAngle = 270
|
||||||
|
case .landscapeLeft:
|
||||||
|
rotationAngle = 0
|
||||||
|
case .landscapeRight:
|
||||||
|
rotationAngle = 180
|
||||||
|
default:
|
||||||
|
rotationAngle = 90 // Default to portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use modern rotation angle API (iOS 17+)
|
||||||
|
if connection.isVideoRotationAngleSupported(rotationAngle) {
|
||||||
|
connection.videoRotationAngle = rotationAngle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update video output connection
|
||||||
|
if let videoConnection = videoOutput?.connection(with: .video),
|
||||||
|
videoConnection.isVideoRotationAngleSupported(rotationAngle) {
|
||||||
|
videoConnection.videoRotationAngle = rotationAngle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchCamera() {
|
||||||
|
guard let session = captureSession else { return }
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.inputs.forEach { session.removeInput($0) }
|
||||||
|
|
||||||
|
isUsingFrontCamera.toggle()
|
||||||
|
let position: AVCaptureDevice.Position = isUsingFrontCamera ? .front : .back
|
||||||
|
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
|
||||||
|
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||||
|
if session.canAddInput(input) {
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
session.commitConfiguration()
|
||||||
|
|
||||||
|
// Update Center Stage availability (only works on front camera)
|
||||||
|
updateCenterStageAvailability()
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePhoto() {
|
||||||
|
// If front flash is enabled, hide the preview to show the ring light
|
||||||
|
if settings.isFrontFlashEnabled {
|
||||||
|
performFrontFlashCapture()
|
||||||
|
} else {
|
||||||
|
let captureSettings = AVCapturePhotoSettings()
|
||||||
|
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs photo capture with front flash effect
|
||||||
|
private func performFrontFlashCapture() {
|
||||||
|
isPreviewHidden = true
|
||||||
|
|
||||||
|
// Brief delay to show the full ring light before capturing
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
|
||||||
|
let captureSettings = AVCapturePhotoSettings()
|
||||||
|
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores the preview after front flash capture
|
||||||
|
func restorePreviewAfterFlash() {
|
||||||
|
isPreviewHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRecording() {
|
||||||
|
guard let videoOutput = videoOutput, !isRecording else { return }
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("video.mov")
|
||||||
|
videoOutput.startRecording(to: url, recordingDelegate: self)
|
||||||
|
isRecording = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording() {
|
||||||
|
guard let videoOutput = videoOutput, isRecording else { return }
|
||||||
|
videoOutput.stopRecording()
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreBrightness() {
|
||||||
|
setBrightness(originalBrightness)
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic: Check if ready to capture
|
||||||
|
var canCapture: Bool {
|
||||||
|
captureSession?.isRunning == true && isPhotoLibraryAuthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Post-Capture Actions
|
||||||
|
|
||||||
|
/// Dismisses the post-capture preview and returns to camera
|
||||||
|
func dismissPostCapturePreview() {
|
||||||
|
showPostCapturePreview = false
|
||||||
|
capturedMedia = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retakes by dismissing preview (deletes unsaved temp if needed)
|
||||||
|
func retakeCapture() {
|
||||||
|
// If auto-save was off and there's temp media, it's discarded
|
||||||
|
dismissPostCapturePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually saves current capture to Photo Library
|
||||||
|
func saveCurrentCapture() {
|
||||||
|
guard let media = capturedMedia else { return }
|
||||||
|
|
||||||
|
switch media {
|
||||||
|
case .photo(let image):
|
||||||
|
if let data = image.jpegData(compressionQuality: 0.9) {
|
||||||
|
savePhotoToLibrary(data: data)
|
||||||
|
showToast(String(localized: "Saved to Photos"))
|
||||||
|
}
|
||||||
|
case .video(let url), .boomerang(let url):
|
||||||
|
saveVideoToLibrary(url: url)
|
||||||
|
showToast(String(localized: "Saved to Photos"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets shareable items for the current capture
|
||||||
|
func getShareItems() -> [Any] {
|
||||||
|
guard let media = capturedMedia else { return [] }
|
||||||
|
|
||||||
|
switch media {
|
||||||
|
case .photo(let image):
|
||||||
|
return [image]
|
||||||
|
case .video(let url), .boomerang(let url):
|
||||||
|
return [url]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toast
|
||||||
|
|
||||||
|
/// Shows a toast message briefly
|
||||||
|
func showToast(_ message: String) {
|
||||||
|
toastMessage = message
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
if toastMessage == message {
|
||||||
|
toastMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
|
||||||
|
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||||
|
guard let data = photo.fileDataRepresentation(),
|
||||||
|
let image = UIImage(data: data) else { return }
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
// Restore preview first (in case front flash was used)
|
||||||
|
restorePreviewAfterFlash()
|
||||||
|
|
||||||
|
// Store the captured image for preview
|
||||||
|
capturedMedia = .photo(image)
|
||||||
|
|
||||||
|
// Auto-save if enabled
|
||||||
|
if settings.isAutoSaveEnabled {
|
||||||
|
savePhotoToLibrary(data: data)
|
||||||
|
showToast(String(localized: "Saved to Photos"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show post-capture preview
|
||||||
|
showPostCapturePreview = true
|
||||||
|
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves photo data to Photo Library
|
||||||
|
private func savePhotoToLibrary(data: Data) {
|
||||||
|
PHPhotoLibrary.shared().performChanges {
|
||||||
|
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCaptureFileOutputRecordingDelegate {
|
||||||
|
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||||
|
Task { @MainActor in
|
||||||
|
// Store the video URL for preview
|
||||||
|
let isBoomerang = settings.selectedCaptureMode == .boomerang
|
||||||
|
capturedMedia = isBoomerang ? .boomerang(outputFileURL) : .video(outputFileURL)
|
||||||
|
|
||||||
|
// Auto-save if enabled
|
||||||
|
if settings.isAutoSaveEnabled {
|
||||||
|
saveVideoToLibrary(url: outputFileURL)
|
||||||
|
showToast(String(localized: "Saved to Photos"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show post-capture preview
|
||||||
|
showPostCapturePreview = true
|
||||||
|
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves video to Photo Library
|
||||||
|
private func saveVideoToLibrary(url: URL) {
|
||||||
|
PHPhotoLibrary.shared().performChanges {
|
||||||
|
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||||
|
// Note: This runs on a background queue and cannot access @MainActor isolated properties directly
|
||||||
|
// For real skin smoothing, this would need to be implemented with a Metal-based approach
|
||||||
|
// or by using AVCaptureVideoDataOutput with custom rendering
|
||||||
|
|
||||||
|
// Basic skin smoothing placeholder - actual implementation would require:
|
||||||
|
// 1. CIContext created on this queue
|
||||||
|
// 2. Rendering to a Metal texture
|
||||||
|
// 3. Displaying via CAMetalLayer or similar
|
||||||
|
|
||||||
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
|
||||||
|
|
||||||
|
// Apply light gaussian blur for skin smoothing effect
|
||||||
|
guard let filter = CIFilter(name: "CIGaussianBlur") else { return }
|
||||||
|
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
||||||
|
filter.setValue(1.0, forKey: kCIInputRadiusKey)
|
||||||
|
|
||||||
|
// For a complete implementation, render outputImage to the preview layer
|
||||||
|
_ = filter.outputImage
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,119 +1,161 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MijickCamera
|
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import Photos
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var settings = SettingsViewModel()
|
@State private var viewModel = CameraViewModel()
|
||||||
@State private var premiumManager = PremiumManager()
|
@State private var premiumManager = PremiumManager()
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var toastMessage: String?
|
@State private var showShareSheet = false
|
||||||
|
|
||||||
// Captured media for post-capture preview
|
|
||||||
@State private var capturedImage: UIImage?
|
|
||||||
@State private var capturedVideoURL: URL?
|
|
||||||
@State private var showPostCapturePreview = false
|
|
||||||
|
|
||||||
// Center Stage support
|
|
||||||
@State private var isCenterStageAvailable = false
|
|
||||||
@State private var isCenterStageEnabled = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let maxRingSize = calculateMaxRingSize(for: geometry)
|
let maxRingSize = calculateMaxRingSize(for: geometry)
|
||||||
let effectiveRingSize = min(settings.ringSize, maxRingSize)
|
let effectiveRingSize = min(viewModel.settings.ringSize, maxRingSize)
|
||||||
|
|
||||||
// Use MCamera as the base, with ring light as border
|
ZStack {
|
||||||
MCamera()
|
// MARK: - Ring Light Background
|
||||||
.onImageCaptured { image, _ in
|
ringLightBackground
|
||||||
handleImageCaptured(image)
|
|
||||||
}
|
// MARK: - Camera Preview (full screen with ring border)
|
||||||
.onVideoCaptured { url, _ in
|
cameraPreviewArea(ringSize: effectiveRingSize)
|
||||||
handleVideoCaptured(url)
|
|
||||||
}
|
// MARK: - Grid Overlay
|
||||||
.ignoresSafeArea()
|
if viewModel.settings.isGridVisible && !viewModel.isPreviewHidden {
|
||||||
.overlay {
|
|
||||||
// Ring light border overlay
|
|
||||||
ringLightOverlay(ringSize: effectiveRingSize)
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
// Grid overlay
|
|
||||||
if settings.isGridVisible {
|
|
||||||
GridOverlay(isVisible: true)
|
GridOverlay(isVisible: true)
|
||||||
.padding(effectiveRingSize)
|
.padding(effectiveRingSize)
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls Overlay (on top of preview)
|
||||||
|
controlsOverlay(ringSize: effectiveRingSize)
|
||||||
|
|
||||||
|
// MARK: - Permission Denied View
|
||||||
|
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
|
||||||
|
permissionDeniedView
|
||||||
}
|
}
|
||||||
.overlay {
|
|
||||||
// Top controls
|
// MARK: - Toast Notification
|
||||||
VStack {
|
if let message = viewModel.toastMessage {
|
||||||
topControlBar
|
|
||||||
.padding(.top, effectiveRingSize + Design.Spacing.small)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
// Toast
|
|
||||||
if let message = toastMessage {
|
|
||||||
toastView(message: message)
|
toastView(message: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: geometry.size) { _, newSize in
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
|
// Update max ring size when screen size changes
|
||||||
let newMax = min(newSize.width, newSize.height) / 4
|
let newMax = min(newSize.width, newSize.height) / 4
|
||||||
if settings.ringSize > newMax {
|
if viewModel.settings.ringSize > newMax {
|
||||||
settings.ringSize = newMax
|
viewModel.settings.ringSize = newMax
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.ignoresSafeArea()
|
||||||
checkCenterStageAvailability()
|
.task {
|
||||||
|
await viewModel.setupCamera()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.restoreBrightness()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||||
|
viewModel.updateVideoOrientation(for: UIDevice.current.orientation)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
ProPaywallView()
|
ProPaywallView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall)
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $viewModel.showPostCapturePreview) {
|
||||||
|
if let media = viewModel.capturedMedia {
|
||||||
|
PostCapturePreviewView(
|
||||||
|
media: media,
|
||||||
|
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
||||||
|
onRetake: {
|
||||||
|
viewModel.retakeCapture()
|
||||||
|
},
|
||||||
|
onSave: {
|
||||||
|
viewModel.saveCurrentCapture()
|
||||||
|
},
|
||||||
|
onShare: {
|
||||||
|
showShareSheet = true
|
||||||
|
},
|
||||||
|
onDismiss: {
|
||||||
|
viewModel.dismissPostCapturePreview()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
ShareSheet(items: viewModel.getShareItems())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showPostCapturePreview) {
|
|
||||||
postCaptureView
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Max Ring Size Calculation
|
// MARK: - Max Ring Size Calculation
|
||||||
|
|
||||||
|
/// Calculates maximum ring size based on screen dimensions
|
||||||
|
/// Ring should not exceed 1/4 of the smaller dimension
|
||||||
private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat {
|
private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat {
|
||||||
min(geometry.size.width, geometry.size.height) / 4
|
min(geometry.size.width, geometry.size.height) / 4
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ring Light Overlay
|
// MARK: - Ring Light Background
|
||||||
|
|
||||||
/// Creates a ring light effect as a border around the camera
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func ringLightOverlay(ringSize: CGFloat) -> some View {
|
private var ringLightBackground: some View {
|
||||||
// Use a rectangle with a large inner cutout to create the ring effect
|
// Always use the selected light color - premium checks are done in Settings
|
||||||
GeometryReader { geo in
|
viewModel.settings.lightColor
|
||||||
let innerRect = CGRect(
|
.ignoresSafeArea()
|
||||||
x: ringSize,
|
|
||||||
y: ringSize,
|
|
||||||
width: geo.size.width - (ringSize * 2),
|
|
||||||
height: geo.size.height - (ringSize * 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
settings.lightColor
|
|
||||||
.mask(
|
|
||||||
Rectangle()
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
||||||
.frame(width: innerRect.width, height: innerRect.height)
|
|
||||||
.blendMode(.destinationOut)
|
|
||||||
)
|
|
||||||
.compositingGroup()
|
|
||||||
)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Preview Area
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cameraPreviewArea(ringSize: CGFloat) -> some View {
|
||||||
|
if viewModel.isCameraAuthorized {
|
||||||
|
// Show preview unless front flash is active
|
||||||
|
if !viewModel.isPreviewHidden {
|
||||||
|
CameraPreview(
|
||||||
|
viewModel: viewModel,
|
||||||
|
isMirrorFlipped: viewModel.settings.isMirrorFlipped,
|
||||||
|
zoomFactor: viewModel.settings.currentZoomFactor,
|
||||||
|
manualRotationAngle: viewModel.manualRotationAngle,
|
||||||
|
ringLightColor: viewModel.settings.lightColor
|
||||||
|
)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.padding(ringSize)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: viewModel.manualRotationAngle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show placeholder while requesting permission
|
||||||
|
Rectangle()
|
||||||
|
.fill(viewModel.settings.lightColor)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.padding(ringSize)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||||
|
.overlay {
|
||||||
|
if viewModel.captureSession == nil {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls Overlay
|
||||||
|
|
||||||
|
private func controlsOverlay(ringSize: CGFloat) -> some View {
|
||||||
|
VStack {
|
||||||
|
// Top bar
|
||||||
|
topControlBar
|
||||||
|
.padding(.top, ringSize + Design.Spacing.small)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom capture controls
|
||||||
|
bottomControlBar
|
||||||
|
.padding(.bottom, ringSize + Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, ringSize + Design.Spacing.small)
|
||||||
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,35 +164,51 @@ struct ContentView: View {
|
|||||||
private var topControlBar: some View {
|
private var topControlBar: some View {
|
||||||
HStack {
|
HStack {
|
||||||
// Center Stage button (only shown when available)
|
// Center Stage button (only shown when available)
|
||||||
if isCenterStageAvailable {
|
if viewModel.isCenterStageAvailable {
|
||||||
Button {
|
Button {
|
||||||
toggleCenterStage()
|
viewModel.toggleCenterStage()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
|
Image(systemName: viewModel.isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(isCenterStageEnabled ? .yellow : .white)
|
.foregroundStyle(viewModel.isCenterStageEnabled ? .yellow : .white)
|
||||||
.padding(Design.Spacing.small)
|
.padding(Design.Spacing.small)
|
||||||
.background(.ultraThinMaterial, in: Circle())
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "Center Stage"))
|
.accessibilityLabel(String(localized: "Center Stage"))
|
||||||
.accessibilityValue(isCenterStageEnabled ? "On" : "Off")
|
.accessibilityValue(viewModel.isCenterStageEnabled ? "On" : "Off")
|
||||||
.accessibilityHint(String(localized: "Keeps you centered in frame"))
|
.accessibilityHint(String(localized: "Keeps you centered in frame"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rotate preview button
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
|
viewModel.cycleManualRotation()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: rotationIconName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(viewModel.manualRotationAngle != 0 ? .yellow : .white)
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Rotate preview"))
|
||||||
|
.accessibilityValue(rotationAccessibilityValue)
|
||||||
|
.accessibilityHint(String(localized: "Rotates the camera preview 90 degrees"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Grid toggle
|
// Grid toggle
|
||||||
Button {
|
Button {
|
||||||
settings.isGridVisible.toggle()
|
viewModel.settings.isGridVisible.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.grid.3x3")
|
Image(systemName: "square.grid.3x3")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(settings.isGridVisible ? .yellow : .white)
|
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
|
||||||
.padding(Design.Spacing.small)
|
.padding(Design.Spacing.small)
|
||||||
.background(.ultraThinMaterial, in: Circle())
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "Toggle grid"))
|
.accessibilityLabel(String(localized: "Toggle grid"))
|
||||||
.accessibilityValue(settings.isGridVisible ? "On" : "Off")
|
.accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off")
|
||||||
|
|
||||||
// Settings button
|
// Settings button
|
||||||
Button {
|
Button {
|
||||||
@ -160,52 +218,182 @@ struct ContentView: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(Design.Spacing.small)
|
.padding(Design.Spacing.small)
|
||||||
.background(.ultraThinMaterial, in: Circle())
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "Settings"))
|
.accessibilityLabel(String(localized: "Settings"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Center Stage
|
/// Icon name for rotation button based on current rotation
|
||||||
|
private var rotationIconName: String {
|
||||||
private func checkCenterStageAvailability() {
|
switch viewModel.manualRotationAngle {
|
||||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
case 90:
|
||||||
isCenterStageAvailable = false
|
return "rotate.right.fill"
|
||||||
return
|
case 180:
|
||||||
|
return "arrow.up.arrow.down"
|
||||||
|
case 270:
|
||||||
|
return "rotate.left.fill"
|
||||||
|
default:
|
||||||
|
return "rotate.right"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCenterStageAvailable = device.activeFormat.isCenterStageSupported
|
/// Accessibility value for rotation button
|
||||||
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
private var rotationAccessibilityValue: String {
|
||||||
|
switch viewModel.manualRotationAngle {
|
||||||
|
case 0:
|
||||||
|
return String(localized: "No rotation")
|
||||||
|
case 90:
|
||||||
|
return String(localized: "Rotated 90 degrees right")
|
||||||
|
case 180:
|
||||||
|
return String(localized: "Rotated 180 degrees")
|
||||||
|
case 270:
|
||||||
|
return String(localized: "Rotated 90 degrees left")
|
||||||
|
default:
|
||||||
|
return String(localized: "Custom rotation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleCenterStage() {
|
// MARK: - Bottom Control Bar
|
||||||
AVCaptureDevice.centerStageControlMode = .app
|
|
||||||
AVCaptureDevice.isCenterStageEnabled.toggle()
|
private var bottomControlBar: some View {
|
||||||
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
HStack {
|
||||||
|
// Switch camera button
|
||||||
|
Button {
|
||||||
|
viewModel.switchCamera()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "camera.rotate.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Switch camera"))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Capture button
|
||||||
|
captureButton
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Capture mode selector
|
||||||
|
captureModeMenu
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Post Capture View
|
// MARK: - Capture Button
|
||||||
|
|
||||||
@ViewBuilder
|
private var captureButton: some View {
|
||||||
private var postCaptureView: some View {
|
Button {
|
||||||
if let image = capturedImage {
|
captureAction()
|
||||||
PostCapturePreviewView(
|
} label: {
|
||||||
media: .photo(image),
|
ZStack {
|
||||||
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
Circle()
|
||||||
onRetake: { dismissPostCapture() },
|
.fill(.white)
|
||||||
onSave: { saveImage(image) },
|
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
|
||||||
onShare: {},
|
|
||||||
onDismiss: { dismissPostCapture() }
|
Circle()
|
||||||
)
|
.stroke(.white, lineWidth: Design.LineWidth.thick)
|
||||||
} else if let url = capturedVideoURL {
|
.frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small)
|
||||||
PostCapturePreviewView(
|
|
||||||
media: .video(url),
|
// Show red stop square when recording
|
||||||
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
if viewModel.isRecording {
|
||||||
onRetake: { dismissPostCapture() },
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
||||||
onSave: { saveVideo(url) },
|
.fill(.red)
|
||||||
onShare: {},
|
.frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare)
|
||||||
onDismiss: { dismissPostCapture() }
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(captureButtonLabel)
|
||||||
|
.disabled(!viewModel.canCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Mode Menu
|
||||||
|
|
||||||
|
private var captureModeMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(CaptureMode.allCases) { mode in
|
||||||
|
Button {
|
||||||
|
if !mode.isPremium || premiumManager.isPremiumUnlocked {
|
||||||
|
viewModel.settings.selectedCaptureMode = mode
|
||||||
|
} else {
|
||||||
|
showPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(mode.displayName, systemImage: mode.systemImage)
|
||||||
|
if mode.isPremium && !premiumManager.isPremiumUnlocked {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.settings.selectedCaptureMode.systemImage)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(.ultraThinMaterial, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Denied View
|
||||||
|
|
||||||
|
private var permissionDeniedView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: Design.BaseFontSize.hero))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Text("Camera Access Required")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Please enable camera access in Settings to use SelfieRingLight.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
|
||||||
|
Button("Open Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(.black.opacity(Design.Opacity.heavy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture Action
|
||||||
|
|
||||||
|
private func captureAction() {
|
||||||
|
switch viewModel.settings.selectedCaptureMode {
|
||||||
|
case .photo:
|
||||||
|
viewModel.capturePhoto()
|
||||||
|
case .video:
|
||||||
|
if viewModel.isRecording {
|
||||||
|
viewModel.stopRecording()
|
||||||
|
} else {
|
||||||
|
viewModel.startRecording()
|
||||||
|
}
|
||||||
|
case .boomerang:
|
||||||
|
// TODO: Implement boomerang capture
|
||||||
|
viewModel.capturePhoto()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var captureButtonLabel: String {
|
||||||
|
switch viewModel.settings.selectedCaptureMode {
|
||||||
|
case .photo:
|
||||||
|
return String(localized: "Take photo")
|
||||||
|
case .video:
|
||||||
|
return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording")
|
||||||
|
case .boomerang:
|
||||||
|
return String(localized: "Capture boomerang")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,61 +415,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(message)
|
.accessibilityLabel(message)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Capture Handlers
|
// MARK: - Share Sheet
|
||||||
|
|
||||||
private func handleImageCaptured(_ image: UIImage) {
|
/// UIKit wrapper for UIActivityViewController
|
||||||
capturedImage = image
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
|
||||||
if settings.isAutoSaveEnabled {
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
saveImage(image)
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
showToast(String(localized: "Saved to Photos"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showPostCapturePreview = true
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
}
|
|
||||||
|
|
||||||
private func handleVideoCaptured(_ url: URL) {
|
|
||||||
capturedVideoURL = url
|
|
||||||
|
|
||||||
if settings.isAutoSaveEnabled {
|
|
||||||
saveVideo(url)
|
|
||||||
showToast(String(localized: "Saved to Photos"))
|
|
||||||
}
|
|
||||||
|
|
||||||
showPostCapturePreview = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissPostCapture() {
|
|
||||||
showPostCapturePreview = false
|
|
||||||
capturedImage = nil
|
|
||||||
capturedVideoURL = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveImage(_ image: UIImage) {
|
|
||||||
guard let data = image.jpegData(compressionQuality: 0.9) else { return }
|
|
||||||
|
|
||||||
PHPhotoLibrary.shared().performChanges {
|
|
||||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveVideo(_ url: URL) {
|
|
||||||
PHPhotoLibrary.shared().performChanges {
|
|
||||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showToast(_ message: String) {
|
|
||||||
toastMessage = message
|
|
||||||
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(for: .seconds(2))
|
|
||||||
if toastMessage == message {
|
|
||||||
toastMessage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user