Integrate MijickCamera with ring light effect
- Add MijickCamera package for camera handling - Create RingLightCameraScreen conforming to MCameraScreen protocol - Ring light background fills screen with settings.lightColor - Camera preview padded inward to create ring effect - Custom capture button and controls overlay - Grid overlay support - Camera flip button using MijickCamera API - Delete old CameraViewModel and CameraPreview (replaced by MijickCamera) - Simplified PostCapturePreviewView for photo/video preview
This commit is contained in:
parent
72bac70ea1
commit
f38e0bf22c
@ -1,233 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,393 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -2,15 +2,72 @@ import SwiftUI
|
||||
import MijickCamera
|
||||
import Bedrock
|
||||
|
||||
/// Simple test view to verify MijickCamera works
|
||||
/// Main camera view with ring light effect using MijickCamera
|
||||
struct ContentView: View {
|
||||
@State private var settings = SettingsViewModel()
|
||||
@State private var premiumManager = PremiumManager()
|
||||
@State private var showSettings = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
// Post-capture state
|
||||
@State private var capturedImage: UIImage?
|
||||
@State private var capturedVideoURL: URL?
|
||||
@State private var showPostCapture = false
|
||||
|
||||
var body: some View {
|
||||
// Just MCamera - nothing else - to test if it renders
|
||||
MCamera()
|
||||
.onImageCaptured { image, _ in
|
||||
print("Image captured!")
|
||||
.setCameraScreen { manager, namespace, closeAction in
|
||||
RingLightCameraScreen(
|
||||
cameraManager: manager,
|
||||
namespace: namespace,
|
||||
closeMCameraAction: closeAction,
|
||||
settings: settings,
|
||||
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
||||
onSettingsTapped: { showSettings = true }
|
||||
)
|
||||
}
|
||||
.onImageCaptured { image, _ in
|
||||
capturedImage = image
|
||||
showPostCapture = true
|
||||
}
|
||||
.onVideoCaptured { url, _ in
|
||||
capturedVideoURL = url
|
||||
showPostCapture = true
|
||||
}
|
||||
.startSession()
|
||||
.ignoresSafeArea()
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
ProPaywallView()
|
||||
}
|
||||
.fullScreenCover(isPresented: $showPostCapture) {
|
||||
PostCapturePreviewView(
|
||||
capturedImage: capturedImage,
|
||||
capturedVideoURL: capturedVideoURL,
|
||||
isAutoSaveEnabled: settings.isAutoSaveEnabled,
|
||||
onRetake: {
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
showPostCapture = false
|
||||
},
|
||||
onSave: {
|
||||
saveCapture()
|
||||
showPostCapture = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Capture
|
||||
|
||||
private func saveCapture() {
|
||||
if let image = capturedImage {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,35 +2,19 @@ import SwiftUI
|
||||
import AVKit
|
||||
import Bedrock
|
||||
|
||||
// MARK: - Captured Media Type
|
||||
|
||||
/// Represents captured media for preview
|
||||
enum CapturedMedia: Equatable {
|
||||
case photo(UIImage)
|
||||
case video(URL)
|
||||
case boomerang(URL)
|
||||
|
||||
var isVideo: Bool {
|
||||
switch self {
|
||||
case .photo: return false
|
||||
case .video, .boomerang: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post Capture Preview View
|
||||
|
||||
/// Full-screen preview shown after photo/video capture
|
||||
struct PostCapturePreviewView: View {
|
||||
let media: CapturedMedia
|
||||
let isPremiumUnlocked: Bool
|
||||
let capturedImage: UIImage?
|
||||
let capturedVideoURL: URL?
|
||||
let isAutoSaveEnabled: Bool
|
||||
let onRetake: () -> Void
|
||||
let onSave: () -> Void
|
||||
let onShare: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var showEditSheet = false
|
||||
@State private var player: AVPlayer?
|
||||
@State private var showShareSheet = false
|
||||
@State private var toastMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -50,18 +34,27 @@ struct PostCapturePreviewView: View {
|
||||
// Bottom toolbar
|
||||
bottomToolbar
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
if let message = toastMessage {
|
||||
toastView(message: message)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupVideoPlayerIfNeeded()
|
||||
if isAutoSaveEnabled {
|
||||
autoSave()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
}
|
||||
.sheet(isPresented: $showEditSheet) {
|
||||
PostCaptureEditView(
|
||||
media: media,
|
||||
isPremiumUnlocked: isPremiumUnlocked
|
||||
)
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let image = capturedImage {
|
||||
ShareSheet(items: [image])
|
||||
} else if let url = capturedVideoURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,28 +62,20 @@ struct PostCapturePreviewView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaPreview: some View {
|
||||
switch media {
|
||||
case .photo(let image):
|
||||
if let image = capturedImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.accessibilityLabel(String(localized: "Captured photo"))
|
||||
|
||||
case .video(let url), .boomerang(let url):
|
||||
if let player {
|
||||
VideoPlayer(player: player)
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
.accessibilityLabel(
|
||||
media == .boomerang(url)
|
||||
? String(localized: "Captured boomerang")
|
||||
: String(localized: "Captured video")
|
||||
)
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
} else if let _ = capturedVideoURL, let player {
|
||||
VideoPlayer(player: player)
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Captured video"))
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,7 +84,7 @@ struct PostCapturePreviewView: View {
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
onDismiss()
|
||||
onRetake()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title2)
|
||||
@ -128,20 +113,25 @@ struct PostCapturePreviewView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Edit button
|
||||
ToolbarButton(
|
||||
title: String(localized: "Edit"),
|
||||
systemImage: "slider.horizontal.3",
|
||||
action: { showEditSheet = true }
|
||||
)
|
||||
// Save button (if not auto-saved)
|
||||
if !isAutoSaveEnabled {
|
||||
ToolbarButton(
|
||||
title: String(localized: "Save"),
|
||||
systemImage: "square.and.arrow.down",
|
||||
action: {
|
||||
onSave()
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Share button
|
||||
ToolbarButton(
|
||||
title: String(localized: "Share"),
|
||||
systemImage: "square.and.arrow.up",
|
||||
action: onShare
|
||||
action: { showShareSheet = true }
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
@ -149,26 +139,51 @@ struct PostCapturePreviewView: View {
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// MARK: - Toast View
|
||||
|
||||
private func toastView(message: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
Text(message)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(.ultraThinMaterial, in: .capsule)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.easeInOut, value: toastMessage)
|
||||
}
|
||||
|
||||
// MARK: - Video Setup
|
||||
|
||||
private func setupVideoPlayerIfNeeded() {
|
||||
switch media {
|
||||
case .photo:
|
||||
break
|
||||
case .video(let url):
|
||||
player = AVPlayer(url: url)
|
||||
case .boomerang(let url):
|
||||
let player = AVPlayer(url: url)
|
||||
// Loop boomerang videos
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: player.currentItem,
|
||||
queue: .main
|
||||
) { _ in
|
||||
player.seek(to: .zero)
|
||||
player.play()
|
||||
guard let url = capturedVideoURL else { return }
|
||||
player = AVPlayer(url: url)
|
||||
}
|
||||
|
||||
// MARK: - Auto Save
|
||||
|
||||
private func autoSave() {
|
||||
if let image = capturedImage {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
// Video saving would go here
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
withAnimation {
|
||||
toastMessage = message
|
||||
}
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
withAnimation {
|
||||
toastMessage = nil
|
||||
}
|
||||
self.player = player
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -194,153 +209,24 @@ private struct ToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post Capture Edit View
|
||||
// MARK: - Share Sheet
|
||||
|
||||
/// Lightweight editor for captured media
|
||||
struct PostCaptureEditView: View {
|
||||
let media: CapturedMedia
|
||||
let isPremiumUnlocked: Bool
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var smoothingIntensity: Double = 0.5
|
||||
@State private var glowIntensity: Double = 0.3
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Preview (simplified for now)
|
||||
mediaPreview
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
// Edit controls
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Smoothing slider
|
||||
EditSlider(
|
||||
title: String(localized: "Smoothing"),
|
||||
value: $smoothingIntensity,
|
||||
systemImage: "wand.and.stars"
|
||||
)
|
||||
|
||||
// Glow effect slider
|
||||
EditSlider(
|
||||
title: String(localized: "Ring Glow"),
|
||||
value: $glowIntensity,
|
||||
systemImage: "light.max"
|
||||
)
|
||||
|
||||
// Premium tools placeholder
|
||||
if !isPremiumUnlocked {
|
||||
premiumToolsTeaser
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.bottom, Design.Spacing.large)
|
||||
}
|
||||
.background(Color.Surface.overlay)
|
||||
.navigationTitle(String(localized: "Edit"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String(localized: "Cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(String(localized: "Done")) {
|
||||
// Apply edits and dismiss
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(Color.Accent.primary)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaPreview: some View {
|
||||
switch media {
|
||||
case .photo(let image):
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
.padding(Design.Spacing.medium)
|
||||
case .video, .boomerang:
|
||||
// Video editing would show a frame or thumbnail
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(.gray.opacity(Design.Opacity.medium))
|
||||
.overlay {
|
||||
Image(systemName: "video.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var premiumToolsTeaser: some View {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
|
||||
Text(String(localized: "Unlock filters, AI remove, and more with Pro"))
|
||||
.font(.system(size: Design.BaseFontSize.caption))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||
.fill(Color.Accent.primary.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit Slider
|
||||
|
||||
private struct EditSlider: View {
|
||||
let title: String
|
||||
@Binding var value: Double
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
HStack {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(value * 100))%")
|
||||
.font(.system(size: Design.BaseFontSize.caption, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
Slider(value: $value, in: 0...1, step: 0.05)
|
||||
.tint(Color.Accent.primary)
|
||||
}
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PostCapturePreviewView(
|
||||
media: .photo(UIImage(systemName: "photo")!),
|
||||
isPremiumUnlocked: false,
|
||||
capturedImage: UIImage(systemName: "photo"),
|
||||
capturedVideoURL: nil,
|
||||
isAutoSaveEnabled: false,
|
||||
onRetake: {},
|
||||
onSave: {},
|
||||
onShare: {},
|
||||
onDismiss: {}
|
||||
onSave: {}
|
||||
)
|
||||
}
|
||||
|
||||
151
SelfieRingLight/Features/Camera/RingLightCameraScreen.swift
Normal file
151
SelfieRingLight/Features/Camera/RingLightCameraScreen.swift
Normal file
@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import MijickCamera
|
||||
import Bedrock
|
||||
|
||||
/// Custom MijickCamera screen with ring light effect
|
||||
struct RingLightCameraScreen: MCameraScreen {
|
||||
// Required by MCameraScreen protocol
|
||||
@ObservedObject var cameraManager: CameraManager
|
||||
let namespace: Namespace.ID
|
||||
let closeMCameraAction: () -> ()
|
||||
|
||||
// Our custom properties
|
||||
let settings: SettingsViewModel
|
||||
let isPremiumUnlocked: Bool
|
||||
let onSettingsTapped: () -> Void
|
||||
|
||||
// MARK: - Capture Button Inner Padding
|
||||
private let captureButtonInnerPadding: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Ring light background - fills entire screen
|
||||
settings.lightColor
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Camera preview with ring padding (from MijickCamera)
|
||||
createCameraOutputView()
|
||||
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
||||
.padding(effectiveRingSize)
|
||||
|
||||
// Grid overlay if enabled
|
||||
GridOverlay(isVisible: settings.isGridVisible)
|
||||
.padding(effectiveRingSize)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// Controls overlay
|
||||
VStack {
|
||||
topControlBar
|
||||
Spacer()
|
||||
bottomControlBar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ring Size
|
||||
|
||||
private var effectiveRingSize: CGFloat {
|
||||
min(settings.ringSize, maxAllowedRingSize)
|
||||
}
|
||||
|
||||
private var maxAllowedRingSize: CGFloat {
|
||||
let screenSize = UIScreen.main.bounds.size
|
||||
let smallerDimension = min(screenSize.width, screenSize.height)
|
||||
// Allow ring to take up to 40% of the smaller dimension
|
||||
return smallerDimension * 0.4
|
||||
}
|
||||
|
||||
// MARK: - Top Control Bar
|
||||
|
||||
private var topControlBar: some View {
|
||||
HStack {
|
||||
// Grid toggle
|
||||
Button {
|
||||
settings.isGridVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: settings.isGridVisible ? "square.grid.3x3.fill" : "square.grid.3x3")
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Grid")
|
||||
.accessibilityValue(settings.isGridVisible ? "On" : "Off")
|
||||
.accessibilityHint("Toggles the rule of thirds grid overlay")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Settings
|
||||
Button {
|
||||
onSettingsTapped()
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
.accessibilityHint("Opens settings")
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Control Bar
|
||||
|
||||
private var bottomControlBar: some View {
|
||||
HStack(spacing: Design.Spacing.xLarge) {
|
||||
// Camera flip
|
||||
Button {
|
||||
Task {
|
||||
try? await setCameraPosition(cameraPosition == .front ? .back : .front)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Switch Camera")
|
||||
.accessibilityHint("Switches between front and back camera")
|
||||
|
||||
// Capture button
|
||||
captureButton
|
||||
|
||||
// Placeholder for symmetry
|
||||
Color.clear
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.bottom, Design.Spacing.large)
|
||||
}
|
||||
|
||||
// MARK: - Capture Button
|
||||
|
||||
private var captureButton: some View {
|
||||
Button {
|
||||
captureOutput()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
|
||||
|
||||
Circle()
|
||||
.strokeBorder(settings.lightColor, lineWidth: Design.LineWidth.thick)
|
||||
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
|
||||
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(
|
||||
width: Design.Capture.buttonSize - captureButtonInnerPadding,
|
||||
height: Design.Capture.buttonSize - captureButtonInnerPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Capture")
|
||||
.accessibilityHint("Takes a photo")
|
||||
}
|
||||
}
|
||||
@ -61,22 +61,10 @@
|
||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Camera Access Required" : {
|
||||
"comment" : "A title displayed in the \"Permission Denied\" view when camera access is required.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "The text for a button that dismisses the current view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Capture boomerang" : {
|
||||
"comment" : "A button label for capturing a boomerang.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Capture mode: %@" : {
|
||||
"comment" : "A label describing the current capture mode. The placeholder is replaced with the actual mode name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Captured boomerang" : {
|
||||
"comment" : "A label describing a captured boomerang.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -89,10 +77,6 @@
|
||||
"comment" : "A label describing a captured video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Center Stage" : {
|
||||
"comment" : "A button that toggles whether the user is centered in the video feed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Close preview" : {
|
||||
"comment" : "A button label that closes the preview screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -109,10 +93,6 @@
|
||||
"comment" : "An accessibility label for the custom color button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Custom rotation" : {
|
||||
"comment" : "An accessibility label for the custom rotation mode in the content view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Debug mode: Purchase simulated!" : {
|
||||
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -153,10 +133,6 @@
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Keeps you centered in frame" : {
|
||||
"comment" : "A hint that explains the purpose of the \"Center Stage\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Last synced %@" : {
|
||||
|
||||
},
|
||||
@ -168,10 +144,6 @@
|
||||
"comment" : "A hint that appears when a user taps on a color preset button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No rotation" : {
|
||||
"comment" : "An accessibility label for a rotation button that is not rotated.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No Watermarks • Ad-Free" : {
|
||||
"comment" : "Description of a benefit that comes with the Pro subscription.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -180,14 +152,6 @@
|
||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"On" : {
|
||||
"comment" : "A label describing a setting that is currently enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open Settings" : {
|
||||
"comment" : "A button label that opens the device settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open Source Licenses" : {
|
||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -203,10 +167,6 @@
|
||||
"comment" : "Voiceover announcement when a photo is captured.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Please enable camera access in Settings to use SelfieRingLight." : {
|
||||
"comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Premium color" : {
|
||||
"comment" : "An accessibility hint for a premium color option in the color preset button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -243,26 +203,6 @@
|
||||
"comment" : "The label for the ring size slider in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotate preview" : {
|
||||
"comment" : "A button that rotates the camera preview by 90 degrees.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotated 90 degrees left" : {
|
||||
"comment" : "An accessibility label describing a 90-degree left rotation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotated 90 degrees right" : {
|
||||
"comment" : "An accessibility label describing a 90-degree clockwise rotation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotated 180 degrees" : {
|
||||
"comment" : "An accessibility label describing a 180-degree rotation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotates the camera preview 90 degrees" : {
|
||||
"comment" : "A hint that explains how to rotate the camera preview.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Saved to Photos" : {
|
||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -310,14 +250,6 @@
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start recording" : {
|
||||
"comment" : "The label for the button that starts recording a video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Stop recording" : {
|
||||
"comment" : "The text label for stopping a video capture.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Subscribe to %@ for %@" : {
|
||||
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -330,10 +262,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Switch camera" : {
|
||||
"comment" : "A button label that says \"Switch camera\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sync Now" : {
|
||||
"comment" : "A button label that triggers a sync action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -352,19 +280,11 @@
|
||||
},
|
||||
"Syncing..." : {
|
||||
|
||||
},
|
||||
"Take photo" : {
|
||||
"comment" : "A button label that says \"Take photo\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Third-party libraries used in this app" : {
|
||||
"comment" : "A description of the third-party libraries used in this app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Toggle grid" : {
|
||||
"comment" : "A button that toggles the visibility of the grid in the camera view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"True Mirror" : {
|
||||
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user