From d93625b2a4963661f7ed1e83d97cd005670ecd6f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 16:06:39 -0600 Subject: [PATCH] Add manual rotation button for camera preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New feature: Rotate preview independent of device orientation - Rotate button in top control bar (next to Center Stage) - Cycles through: 0° → 90° → 180° → 270° → 0° - Yellow highlight when rotation is active - Icon changes to show current rotation state Ring light integration: - Preview background color now matches ring light color - Letterbox areas (from aspect ratio differences) show ring light - Placeholder during permission request also uses ring light color Visual feedback: - rotate.right (outline) = no rotation - rotate.right.fill = 90° right - arrow.up.arrow.down = 180° - rotate.left.fill = 270° (90° left) Full accessibility support with labels and hints. --- .../Features/Camera/CameraPreview.swift | 37 +++++++++---- .../Features/Camera/CameraViewModel.swift | 16 ++++++ .../Features/Camera/ContentView.swift | 53 ++++++++++++++++++- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/SelfieRingLight/Features/Camera/CameraPreview.swift b/SelfieRingLight/Features/Camera/CameraPreview.swift index e761409..126d2c1 100644 --- a/SelfieRingLight/Features/Camera/CameraPreview.swift +++ b/SelfieRingLight/Features/Camera/CameraPreview.swift @@ -8,11 +8,15 @@ struct CameraPreview: UIViewRepresentable { // 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) { + 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 { @@ -28,6 +32,12 @@ struct CameraPreview: UIViewRepresentable { } 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() @@ -94,6 +104,9 @@ 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 } @@ -192,25 +205,29 @@ class CameraPreviewUIView: UIView { lastValidOrientation = deviceOrientation } - // Calculate rotation angle (in degrees) for the preview layer + // Calculate base rotation angle (in degrees) for the preview layer // For front camera in portrait: sensor is landscape, so rotate 90° - let rotationAngle: CGFloat + let baseRotationAngle: CGFloat switch deviceOrientation { case .portrait: - rotationAngle = 90 + baseRotationAngle = 90 case .portraitUpsideDown: - rotationAngle = 270 + baseRotationAngle = 270 case .landscapeLeft: - rotationAngle = 180 + baseRotationAngle = 180 case .landscapeRight: - rotationAngle = 0 + baseRotationAngle = 0 default: - rotationAngle = 90 // Default to portrait + 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(rotationAngle) { - connection.videoRotationAngle = rotationAngle + if connection.isVideoRotationAngleSupported(finalRotation) { + connection.videoRotationAngle = finalRotation } } } diff --git a/SelfieRingLight/Features/Camera/CameraViewModel.swift b/SelfieRingLight/Features/Camera/CameraViewModel.swift index 223ee98..435539f 100644 --- a/SelfieRingLight/Features/Camera/CameraViewModel.swift +++ b/SelfieRingLight/Features/Camera/CameraViewModel.swift @@ -38,8 +38,24 @@ class CameraViewModel: NSObject { /// 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 diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index f3600f0..edf49a9 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -115,16 +115,19 @@ struct ContentView: View { CameraPreview( viewModel: viewModel, isMirrorFlipped: viewModel.settings.isMirrorFlipped, - zoomFactor: viewModel.settings.currentZoomFactor + 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(.black) + .fill(viewModel.settings.lightColor) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .padding(ringSize) .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) @@ -176,6 +179,22 @@ struct ContentView: View { .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() // Grid toggle @@ -205,6 +224,36 @@ struct ContentView: View { } } + /// Icon name for rotation button based on current rotation + private var rotationIconName: String { + switch viewModel.manualRotationAngle { + case 90: + return "rotate.right.fill" + case 180: + return "arrow.up.arrow.down" + case 270: + return "rotate.left.fill" + default: + return "rotate.right" + } + } + + /// Accessibility value for rotation button + 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") + } + } + // MARK: - Bottom Control Bar private var bottomControlBar: some View {