Add manual rotation button for camera preview
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.
This commit is contained in:
parent
6fb3c4e212
commit
d93625b2a4
@ -8,11 +8,15 @@ struct CameraPreview: UIViewRepresentable {
|
|||||||
// These properties trigger view updates when they change
|
// These properties trigger view updates when they change
|
||||||
var isMirrorFlipped: Bool
|
var isMirrorFlipped: Bool
|
||||||
var zoomFactor: Double
|
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.viewModel = viewModel
|
||||||
self.isMirrorFlipped = isMirrorFlipped
|
self.isMirrorFlipped = isMirrorFlipped
|
||||||
self.zoomFactor = zoomFactor
|
self.zoomFactor = zoomFactor
|
||||||
|
self.manualRotationAngle = manualRotationAngle
|
||||||
|
self.ringLightColor = ringLightColor
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> CameraPreviewUIView {
|
func makeUIView(context: Context) -> CameraPreviewUIView {
|
||||||
@ -28,6 +32,12 @@ struct CameraPreview: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
|
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
|
// Force layout update
|
||||||
uiView.setNeedsLayout()
|
uiView.setNeedsLayout()
|
||||||
uiView.layoutIfNeeded()
|
uiView.layoutIfNeeded()
|
||||||
@ -94,6 +104,9 @@ class CameraPreviewUIView: UIView {
|
|||||||
private weak var viewModel: CameraViewModel?
|
private weak var viewModel: CameraViewModel?
|
||||||
var previewLayer: AVCaptureVideoPreviewLayer?
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
/// Manual rotation offset from user (0, 90, 180, 270)
|
||||||
|
var manualRotationAngle: CGFloat = 0
|
||||||
|
|
||||||
override class var layerClass: AnyClass {
|
override class var layerClass: AnyClass {
|
||||||
AVCaptureVideoPreviewLayer.self
|
AVCaptureVideoPreviewLayer.self
|
||||||
}
|
}
|
||||||
@ -192,25 +205,29 @@ class CameraPreviewUIView: UIView {
|
|||||||
lastValidOrientation = deviceOrientation
|
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°
|
// For front camera in portrait: sensor is landscape, so rotate 90°
|
||||||
let rotationAngle: CGFloat
|
let baseRotationAngle: CGFloat
|
||||||
switch deviceOrientation {
|
switch deviceOrientation {
|
||||||
case .portrait:
|
case .portrait:
|
||||||
rotationAngle = 90
|
baseRotationAngle = 90
|
||||||
case .portraitUpsideDown:
|
case .portraitUpsideDown:
|
||||||
rotationAngle = 270
|
baseRotationAngle = 270
|
||||||
case .landscapeLeft:
|
case .landscapeLeft:
|
||||||
rotationAngle = 180
|
baseRotationAngle = 180
|
||||||
case .landscapeRight:
|
case .landscapeRight:
|
||||||
rotationAngle = 0
|
baseRotationAngle = 0
|
||||||
default:
|
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+)
|
// Use modern rotation angle API (iOS 17+)
|
||||||
if connection.isVideoRotationAngleSupported(rotationAngle) {
|
if connection.isVideoRotationAngleSupported(finalRotation) {
|
||||||
connection.videoRotationAngle = rotationAngle
|
connection.videoRotationAngle = finalRotation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,8 +38,24 @@ class CameraViewModel: NSObject {
|
|||||||
/// Whether Center Stage is currently enabled
|
/// Whether Center Stage is currently enabled
|
||||||
var isCenterStageEnabled = false
|
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
|
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
|
// MARK: - Screen Brightness Handling
|
||||||
|
|
||||||
/// Gets the current screen from any available window scene
|
/// Gets the current screen from any available window scene
|
||||||
|
|||||||
@ -115,16 +115,19 @@ struct ContentView: View {
|
|||||||
CameraPreview(
|
CameraPreview(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
isMirrorFlipped: viewModel.settings.isMirrorFlipped,
|
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))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
.padding(ringSize)
|
.padding(ringSize)
|
||||||
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: viewModel.manualRotationAngle)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show placeholder while requesting permission
|
// Show placeholder while requesting permission
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.black)
|
.fill(viewModel.settings.lightColor)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
.padding(ringSize)
|
.padding(ringSize)
|
||||||
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||||
@ -176,6 +179,22 @@ struct ContentView: View {
|
|||||||
.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
|
||||||
@ -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
|
// MARK: - Bottom Control Bar
|
||||||
|
|
||||||
private var bottomControlBar: some View {
|
private var bottomControlBar: some View {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user