fix: preview fixed

This commit is contained in:
Michael Freno
2026-01-14 01:01:03 -05:00
parent 780da2951b
commit 23000589cf
4 changed files with 116 additions and 54 deletions

View File

@@ -27,6 +27,7 @@ class EnforceModeService: ObservableObject {
self.settingsManager = SettingsManager.shared self.settingsManager = SettingsManager.shared
self.eyeTrackingService = EyeTrackingService.shared self.eyeTrackingService = EyeTrackingService.shared
setupObservers() setupObservers()
initializeEnforceModeState()
} }
private func setupObservers() { private func setupObservers() {
@@ -37,6 +38,20 @@ class EnforceModeService: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
private func initializeEnforceModeState() {
let cameraService = CameraAccessService.shared
let settingsEnabled = settingsManager.settings.enforcementMode
// If settings say it's enabled AND camera is authorized, mark as enabled
if settingsEnabled && cameraService.isCameraAuthorized {
isEnforceModeEnabled = true
print("✓ Enforce mode initialized as enabled (camera authorized)")
} else {
isEnforceModeEnabled = false
print("🔒 Enforce mode initialized as disabled")
}
}
func enableEnforceMode() async { func enableEnforceMode() async {
print("🔒 enableEnforceMode called") print("🔒 enableEnforceMode called")
guard !isEnforceModeEnabled else { guard !isEnforceModeEnabled else {

View File

@@ -21,11 +21,23 @@ class EyeTrackingService: NSObject, ObservableObject {
private var captureSession: AVCaptureSession? private var captureSession: AVCaptureSession?
private var videoOutput: AVCaptureVideoDataOutput? private var videoOutput: AVCaptureVideoDataOutput?
private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated) private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated)
private var _previewLayer: AVCaptureVideoPreviewLayer?
var previewLayer: AVCaptureVideoPreviewLayer? { var previewLayer: AVCaptureVideoPreviewLayer? {
guard let session = captureSession else { return nil } guard let session = captureSession else {
_previewLayer = nil
return nil
}
// Reuse existing layer if session hasn't changed
if let existing = _previewLayer, existing.session === session {
return existing
}
// Create new layer only when session changes
let layer = AVCaptureVideoPreviewLayer(session: session) let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill layer.videoGravity = .resizeAspectFill
_previewLayer = layer
return layer return layer
} }
@@ -66,6 +78,7 @@ class EyeTrackingService: NSObject, ObservableObject {
captureSession?.stopRunning() captureSession?.stopRunning()
captureSession = nil captureSession = nil
videoOutput = nil videoOutput = nil
_previewLayer = nil
isEyeTrackingActive = false isEyeTrackingActive = false
isEyesClosed = false isEyesClosed = false
userLookingAtScreen = true userLookingAtScreen = true

View File

@@ -12,20 +12,36 @@ struct CameraPreviewView: NSViewRepresentable {
let previewLayer: AVCaptureVideoPreviewLayer let previewLayer: AVCaptureVideoPreviewLayer
let borderColor: NSColor let borderColor: NSColor
func makeNSView(context: Context) -> NSView { func makeNSView(context: Context) -> PreviewContainerView {
let view = PreviewContainerView() let view = PreviewContainerView()
view.wantsLayer = true view.wantsLayer = true
previewLayer.frame = view.bounds // Add the preview layer once
view.layer?.addSublayer(previewLayer) if view.layer?.sublayers?.first as? AVCaptureVideoPreviewLayer !== previewLayer {
view.layer?.sublayers?.forEach { $0.removeFromSuperlayer() }
previewLayer.frame = view.bounds
view.layer?.addSublayer(previewLayer)
}
updateBorder(view: view, color: borderColor) updateBorder(view: view, color: borderColor)
return view return view
} }
func updateNSView(_ nsView: NSView, context: Context) { func updateNSView(_ nsView: PreviewContainerView, context: Context) {
previewLayer.frame = nsView.bounds // Only update border color and frame, don't recreate layer
let currentLayer = nsView.layer?.sublayers?.first as? AVCaptureVideoPreviewLayer
if currentLayer !== previewLayer {
// Layer changed, need to replace
nsView.layer?.sublayers?.forEach { $0.removeFromSuperlayer() }
previewLayer.frame = nsView.bounds
nsView.layer?.addSublayer(previewLayer)
} else {
// Same layer, just update frame
previewLayer.frame = nsView.bounds
}
updateBorder(view: nsView, color: borderColor) updateBorder(view: nsView, color: borderColor)
} }

View File

@@ -5,6 +5,7 @@
// Created by Mike Freno on 1/13/26. // Created by Mike Freno on 1/13/26.
// //
import AVFoundation
import SwiftUI import SwiftUI
struct EnforceModeSetupView: View { struct EnforceModeSetupView: View {
@@ -12,10 +13,11 @@ struct EnforceModeSetupView: View {
@ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared @ObservedObject var enforceModeService = EnforceModeService.shared
@State private var isProcessingToggle = false @State private var isProcessingToggle = false
@State private var isTestModeActive = false @State private var isTestModeActive = false
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -27,15 +29,15 @@ struct EnforceModeSetupView: View {
} }
.padding(.top, 20) .padding(.top, 20)
.padding(.bottom, 30) .padding(.bottom, 30)
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
Text("Use your camera to ensure you take breaks") Text("Use your camera to ensure you take breaks")
.font(.title3) .font(.title3)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
VStack(spacing: 20) { VStack(spacing: 20) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -49,8 +51,8 @@ struct EnforceModeSetupView: View {
Toggle( Toggle(
"", "",
isOn: Binding( isOn: Binding(
get: { get: {
settingsManager.settings.enforcementMode settingsManager.settings.enforcementMode
}, },
set: { newValue in set: { newValue in
print("🎛️ Toggle changed to: \(newValue)") print("🎛️ Toggle changed to: \(newValue)")
@@ -68,13 +70,13 @@ struct EnforceModeSetupView: View {
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
cameraStatusView cameraStatusView
if enforceModeService.isEnforceModeEnabled { if enforceModeService.isEnforceModeEnabled {
testModeButton testModeButton
} }
if isTestModeActive && enforceModeService.isCameraActive { if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView testModePreviewView
} else { } else {
@@ -83,28 +85,32 @@ struct EnforceModeSetupView: View {
} else if enforceModeService.isEnforceModeEnabled { } else if enforceModeService.isEnforceModeEnabled {
cameraPendingView cameraPendingView
} }
privacyInfoView privacyInfoView
} }
} }
} }
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
.background(.clear) .background(.clear)
} }
private var testModeButton: some View { private var testModeButton: some View {
Button(action: { Button(action: {
Task { @MainActor in Task { @MainActor in
if isTestModeActive { if isTestModeActive {
enforceModeService.stopTestMode() enforceModeService.stopTestMode()
isTestModeActive = false isTestModeActive = false
cachedPreviewLayer = nil
} else { } else {
await enforceModeService.startTestMode() await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive isTestModeActive = enforceModeService.isCameraActive
if isTestModeActive {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
} }
} }
}) { }) {
@@ -120,53 +126,64 @@ struct EnforceModeSetupView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
} }
private var testModePreviewView: some View { private var testModePreviewView: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
if let previewLayer = eyeTrackingService.previewLayer { let lookingAway = !eyeTrackingService.userLookingAtScreen
let lookingAway = !eyeTrackingService.userLookingAtScreen let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
// Cache the preview layer to avoid recreating it
CameraPreviewView(previewLayer: previewLayer, borderColor: borderColor) let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer
if let layer = previewLayer {
CameraPreviewView(previewLayer: layer, borderColor: borderColor)
.frame(height: 300) .frame(height: 300)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
.onAppear {
if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Live Tracking Status") Text("Live Tracking Status")
.font(.headline) .font(.headline)
HStack(spacing: 20) { HStack(spacing: 20) {
statusIndicator( statusIndicator(
title: "Face Detected", title: "Face Detected",
isActive: eyeTrackingService.faceDetected, isActive: eyeTrackingService.faceDetected,
icon: "person.fill" icon: "person.fill"
) )
statusIndicator( statusIndicator(
title: "Looking Away", title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen, isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right" icon: "arrow.turn.up.right"
) )
} }
Text(lookingAway ? "✓ Break compliance detected" : "⚠️ Please look away from screen") Text(
.font(.caption) lookingAway
.foregroundColor(lookingAway ? .green : .orange) ? "✓ Break compliance detected" : "⚠️ Please look away from screen"
.frame(maxWidth: .infinity, alignment: .center) )
.padding(.top, 4) .font(.caption)
.foregroundColor(lookingAway ? .green : .orange)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 4)
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
} }
} }
} }
private var cameraStatusView: some View { private var cameraStatusView: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Camera Access") Text("Camera Access")
.font(.headline) .font(.headline)
if cameraService.isCameraAuthorized { if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill") Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption) .font(.caption)
@@ -181,9 +198,9 @@ struct EnforceModeSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
Spacer() Spacer()
if !cameraService.isCameraAuthorized { if !cameraService.isCameraAuthorized {
Button("Request Access") { Button("Request Access") {
print("📷 Request Access button clicked") print("📷 Request Access button clicked")
@@ -202,19 +219,19 @@ struct EnforceModeSetupView: View {
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
} }
private var eyeTrackingStatusView: some View { private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status") Text("Eye Tracking Status")
.font(.headline) .font(.headline)
HStack(spacing: 20) { HStack(spacing: 20) {
statusIndicator( statusIndicator(
title: "Face Detected", title: "Face Detected",
isActive: eyeTrackingService.faceDetected, isActive: eyeTrackingService.faceDetected,
icon: "person.fill" icon: "person.fill"
) )
statusIndicator( statusIndicator(
title: "Looking Away", title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen, isActive: !eyeTrackingService.userLookingAtScreen,
@@ -225,13 +242,13 @@ struct EnforceModeSetupView: View {
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
} }
private var cameraPendingView: some View { private var cameraPendingView: some View {
HStack { HStack {
Image(systemName: "timer") Image(systemName: "timer")
.font(.title2) .font(.title2)
.foregroundColor(.orange) .foregroundColor(.orange)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Camera Ready") Text("Camera Ready")
.font(.headline) .font(.headline)
@@ -239,19 +256,19 @@ struct EnforceModeSetupView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
} }
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View { private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: icon) Image(systemName: icon)
.font(.title2) .font(.title2)
.foregroundColor(isActive ? .green : .secondary) .foregroundColor(isActive ? .green : .secondary)
Text(title) Text(title)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -259,7 +276,7 @@ struct EnforceModeSetupView: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
private var privacyInfoView: some View { private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
@@ -269,7 +286,7 @@ struct EnforceModeSetupView: View {
Text("Privacy Information") Text("Privacy Information")
.font(.headline) .font(.headline)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device") privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted") privacyBullet("No images are stored or transmitted")
@@ -281,9 +298,10 @@ struct EnforceModeSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12))
} }
private func privacyBullet(_ text: String) -> some View { private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -292,19 +310,19 @@ struct EnforceModeSetupView: View {
Text(text) Text(text)
} }
} }
private func handleEnforceModeToggle(enabled: Bool) { private func handleEnforceModeToggle(enabled: Bool) {
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)") print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
isProcessingToggle = true isProcessingToggle = true
Task { @MainActor in Task { @MainActor in
defer { isProcessingToggle = false } defer { isProcessingToggle = false }
if enabled { if enabled {
print("🎛️ Enabling enforce mode...") print("🎛️ Enabling enforce mode...")
await enforceModeService.enableEnforceMode() await enforceModeService.enableEnforceMode()
print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)") print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)")
if !enforceModeService.isEnforceModeEnabled { if !enforceModeService.isEnforceModeEnabled {
print("⚠️ Failed to activate, reverting toggle") print("⚠️ Failed to activate, reverting toggle")
settingsManager.settings.enforcementMode = false settingsManager.settings.enforcementMode = false