Fix camera not showing - use MCamera as base with ring overlay

Issue: MCamera wasn't rendering when embedded inside ZStack with
background color in front of it.

Solution: Restructure view hierarchy:
- MCamera is now the base view (not inside a ZStack)
- Ring light effect is now an overlay using mask/blendMode
- Grid, controls, and toast are overlays on top

The ring light overlay uses:
1. Full-screen settings.lightColor
2. A mask with destinationOut blend mode
3. A RoundedRectangle cutout in the center

This allows MCamera to render properly while creating the ring
light border effect around the camera preview.
This commit is contained in:
Matt Bruce 2026-01-02 16:23:35 -06:00
parent b97590f1c1
commit e628897bff
2 changed files with 72 additions and 109 deletions

View File

@ -25,51 +25,49 @@ struct ContentView: View {
let maxRingSize = calculateMaxRingSize(for: geometry) let maxRingSize = calculateMaxRingSize(for: geometry)
let effectiveRingSize = min(settings.ringSize, maxRingSize) let effectiveRingSize = min(settings.ringSize, maxRingSize)
ZStack { // Use MCamera as the base, with ring light as border
// MARK: - Ring Light Background MCamera()
settings.lightColor .onImageCaptured { image, _ in
.ignoresSafeArea() handleImageCaptured(image)
}
// MARK: - Camera with MijickCamera .onVideoCaptured { url, _ in
MCamera() handleVideoCaptured(url)
.onImageCaptured { image, _ in }
handleImageCaptured(image) .ignoresSafeArea()
.overlay {
// Ring light border overlay
ringLightOverlay(ringSize: effectiveRingSize)
}
.overlay {
// Grid overlay
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
} }
.onVideoCaptured { url, _ in }
handleVideoCaptured(url) .overlay {
// Top controls
VStack {
topControlBar
.padding(.top, effectiveRingSize + Design.Spacing.small)
Spacer()
} }
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large)) .padding(.horizontal, effectiveRingSize + Design.Spacing.small)
.padding(effectiveRingSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: effectiveRingSize)
// MARK: - Grid Overlay
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
} }
.overlay {
// MARK: - Top Controls Overlay // Toast
VStack { if let message = toastMessage {
topControlBar toastView(message: message)
.padding(.top, effectiveRingSize + Design.Spacing.small) }
Spacer()
} }
.padding(.horizontal, effectiveRingSize + Design.Spacing.small) .onChange(of: geometry.size) { _, newSize in
let newMax = min(newSize.width, newSize.height) / 4
// MARK: - Toast Notification if settings.ringSize > newMax {
if let message = toastMessage { settings.ringSize = newMax
toastView(message: message) }
} }
}
.onChange(of: geometry.size) { _, newSize in
let newMax = min(newSize.width, newSize.height) / 4
if settings.ringSize > newMax {
settings.ringSize = newMax
}
}
} }
.ignoresSafeArea()
.onAppear { .onAppear {
checkCenterStageAvailability() checkCenterStageAvailability()
} }
@ -90,6 +88,35 @@ struct ContentView: View {
min(geometry.size.width, geometry.size.height) / 4 min(geometry.size.width, geometry.size.height) / 4
} }
// MARK: - Ring Light Overlay
/// Creates a ring light effect as a border around the camera
@ViewBuilder
private func ringLightOverlay(ringSize: CGFloat) -> some View {
// Use a rectangle with a large inner cutout to create the ring effect
GeometryReader { geo in
let innerRect = CGRect(
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)
}
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
}
// MARK: - Top Control Bar // MARK: - Top Control Bar
private var topControlBar: some View { private var topControlBar: some View {
@ -141,20 +168,16 @@ struct ContentView: View {
// MARK: - Center Stage // MARK: - Center Stage
/// Checks if Center Stage is available on this device
private func checkCenterStageAvailability() { private func checkCenterStageAvailability() {
// Center Stage requires a compatible device (iPad with A12+ or some iPhones)
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
isCenterStageAvailable = false isCenterStageAvailable = false
return return
} }
// Check if the device supports Center Stage
isCenterStageAvailable = device.activeFormat.isCenterStageSupported isCenterStageAvailable = device.activeFormat.isCenterStageSupported
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
} }
/// Toggles Center Stage on/off
private func toggleCenterStage() { private func toggleCenterStage() {
AVCaptureDevice.centerStageControlMode = .app AVCaptureDevice.centerStageControlMode = .app
AVCaptureDevice.isCenterStageEnabled.toggle() AVCaptureDevice.isCenterStageEnabled.toggle()

View File

@ -61,22 +61,10 @@
"comment" : "Display name for the \"Boomerang\" capture mode.", "comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Camera Access Required" : {
"comment" : "A title displayed when camera access is denied.",
"isCommentAutoGenerated" : true
},
"Cancel" : { "Cancel" : {
"comment" : "The text for a button that dismisses the current view.", "comment" : "The text for a button that dismisses the current view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Capture boomerang" : {
"comment" : "Label for capturing a boomerang photo.",
"isCommentAutoGenerated" : true
},
"Capture mode: %@" : {
"comment" : "A label describing the currently selected capture mode. The placeholder is replaced with the actual mode name.",
"isCommentAutoGenerated" : true
},
"Captured boomerang" : { "Captured boomerang" : {
"comment" : "A label describing a captured boomerang.", "comment" : "A label describing a captured boomerang.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -109,10 +97,6 @@
"comment" : "An accessibility label for the custom color button.", "comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Custom rotation" : {
"comment" : "Accessibility value for the rotate preview button when the user has customized the rotation angle.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : { "Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.", "comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -168,10 +152,6 @@
"comment" : "A hint that appears when a user taps on a color preset button.", "comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"No rotation" : {
"comment" : "Accessibility value for the rotation button when the preview is not rotated.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : { "No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.", "comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -184,8 +164,8 @@
"comment" : "A label describing a setting that is currently enabled.", "comment" : "A label describing a setting that is currently enabled.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Open Settings" : { "Open Source Licenses" : {
"comment" : "A button label that opens the device settings when tapped.", "comment" : "A heading displayed above a list of open source licenses used in the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Opens upgrade options" : { "Opens upgrade options" : {
@ -194,14 +174,6 @@
}, },
"Photo" : { "Photo" : {
},
"Photo captured" : {
"comment" : "Accessibility label for a notification that is posted 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" : { "Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.", "comment" : "An accessibility hint for a premium color option in the color preset button.",
@ -239,26 +211,6 @@
"comment" : "The label for the ring size slider in the settings view.", "comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Rotate preview" : {
"comment" : "A button that rotates the camera preview by 90 degrees.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees left" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the left.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees right" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the right.",
"isCommentAutoGenerated" : true
},
"Rotated 180 degrees" : {
"comment" : "Accessibility value of the \"Rotate preview\" button when the preview is 180 degrees rotated.",
"isCommentAutoGenerated" : true
},
"Rotates the camera preview 90 degrees" : {
"comment" : "A hint that explains how to rotate the camera preview.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : { "Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.", "comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -306,14 +258,6 @@
"comment" : "Name of a ring light color preset.", "comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Start recording" : {
"comment" : "Label for the \"Start recording\" button in the bottom control bar when not recording a video.",
"isCommentAutoGenerated" : true
},
"Stop recording" : {
"comment" : "Label for the button that stops recording a video.",
"isCommentAutoGenerated" : true
},
"Subscribe to %@ for %@" : { "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.", "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, "isCommentAutoGenerated" : true,
@ -326,10 +270,6 @@
} }
} }
}, },
"Switch camera" : {
"comment" : "A button label that translates to \"Switch camera\".",
"isCommentAutoGenerated" : true
},
"Sync Now" : { "Sync Now" : {
"comment" : "A button label that triggers a sync action.", "comment" : "A button label that triggers a sync action.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -349,8 +289,8 @@
"Syncing..." : { "Syncing..." : {
}, },
"Take photo" : { "Third-party libraries used in this app" : {
"comment" : "Label for the \"Take photo\" button in the bottom control bar when using the photo capture mode.", "comment" : "A description of the third-party libraries used in this app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Toggle grid" : { "Toggle grid" : {
@ -385,8 +325,8 @@
"comment" : "Display name for the \"Video\" capture mode.", "comment" : "Display name for the \"Video\" capture mode.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Video saved" : { "View on GitHub" : {
"comment" : "Accessibility notification text when a video is successfully saved to the user's photo library.", "comment" : "A button label that says \"View on GitHub\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Warm Amber" : { "Warm Amber" : {