diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift index 729bba0..78e6453 100644 --- a/Gaze/Services/EnforceModeService.swift +++ b/Gaze/Services/EnforceModeService.swift @@ -27,6 +27,7 @@ class EnforceModeService: ObservableObject { self.settingsManager = SettingsManager.shared self.eyeTrackingService = EyeTrackingService.shared setupObservers() + initializeEnforceModeState() } private func setupObservers() { @@ -37,6 +38,20 @@ class EnforceModeService: ObservableObject { .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 { print("🔒 enableEnforceMode called") guard !isEnforceModeEnabled else { diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index 84d3682..e09d16c 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -21,11 +21,23 @@ class EyeTrackingService: NSObject, ObservableObject { private var captureSession: AVCaptureSession? private var videoOutput: AVCaptureVideoDataOutput? private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated) + private 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) layer.videoGravity = .resizeAspectFill + _previewLayer = layer return layer } @@ -66,6 +78,7 @@ class EyeTrackingService: NSObject, ObservableObject { captureSession?.stopRunning() captureSession = nil videoOutput = nil + _previewLayer = nil isEyeTrackingActive = false isEyesClosed = false userLookingAtScreen = true diff --git a/Gaze/Views/Components/CameraPreviewView.swift b/Gaze/Views/Components/CameraPreviewView.swift index c30a385..73aa7de 100644 --- a/Gaze/Views/Components/CameraPreviewView.swift +++ b/Gaze/Views/Components/CameraPreviewView.swift @@ -12,20 +12,36 @@ struct CameraPreviewView: NSViewRepresentable { let previewLayer: AVCaptureVideoPreviewLayer let borderColor: NSColor - func makeNSView(context: Context) -> NSView { + func makeNSView(context: Context) -> PreviewContainerView { let view = PreviewContainerView() view.wantsLayer = true - previewLayer.frame = view.bounds - view.layer?.addSublayer(previewLayer) + // Add the preview layer once + 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) return view } - func updateNSView(_ nsView: NSView, context: Context) { - previewLayer.frame = nsView.bounds + func updateNSView(_ nsView: PreviewContainerView, context: Context) { + // 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) } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index fc8db80..2aa8c15 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -5,6 +5,7 @@ // Created by Mike Freno on 1/13/26. // +import AVFoundation import SwiftUI struct EnforceModeSetupView: View { @@ -12,10 +13,11 @@ struct EnforceModeSetupView: View { @ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var eyeTrackingService = EyeTrackingService.shared @ObservedObject var enforceModeService = EnforceModeService.shared - + @State private var isProcessingToggle = false @State private var isTestModeActive = false - + @State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer? + var body: some View { VStack(spacing: 0) { VStack(spacing: 16) { @@ -27,15 +29,15 @@ struct EnforceModeSetupView: View { } .padding(.top, 20) .padding(.bottom, 30) - + Spacer() - + VStack(spacing: 30) { Text("Use your camera to ensure you take breaks") .font(.title3) .foregroundColor(.secondary) .multilineTextAlignment(.center) - + VStack(spacing: 20) { HStack { VStack(alignment: .leading, spacing: 4) { @@ -49,8 +51,8 @@ struct EnforceModeSetupView: View { Toggle( "", isOn: Binding( - get: { - settingsManager.settings.enforcementMode + get: { + settingsManager.settings.enforcementMode }, set: { newValue in print("🎛️ Toggle changed to: \(newValue)") @@ -68,13 +70,13 @@ struct EnforceModeSetupView: View { } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - + cameraStatusView - + if enforceModeService.isEnforceModeEnabled { testModeButton } - + if isTestModeActive && enforceModeService.isCameraActive { testModePreviewView } else { @@ -83,28 +85,32 @@ struct EnforceModeSetupView: View { } else if enforceModeService.isEnforceModeEnabled { cameraPendingView } - + privacyInfoView } } } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.clear) } - + private var testModeButton: some View { Button(action: { Task { @MainActor in if isTestModeActive { enforceModeService.stopTestMode() isTestModeActive = false + cachedPreviewLayer = nil } else { await enforceModeService.startTestMode() isTestModeActive = enforceModeService.isCameraActive + if isTestModeActive { + cachedPreviewLayer = eyeTrackingService.previewLayer + } } } }) { @@ -120,53 +126,64 @@ struct EnforceModeSetupView: View { .buttonStyle(.borderedProminent) .controlSize(.large) } - + private var testModePreviewView: some View { VStack(spacing: 16) { - if let previewLayer = eyeTrackingService.previewLayer { - let lookingAway = !eyeTrackingService.userLookingAtScreen - let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed - - CameraPreviewView(previewLayer: previewLayer, borderColor: borderColor) + let lookingAway = !eyeTrackingService.userLookingAtScreen + let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed + + // Cache the preview layer to avoid recreating it + let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer + + if let layer = previewLayer { + CameraPreviewView(previewLayer: layer, borderColor: borderColor) .frame(height: 300) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - + .onAppear { + if cachedPreviewLayer == nil { + cachedPreviewLayer = eyeTrackingService.previewLayer + } + } + VStack(alignment: .leading, spacing: 12) { Text("Live Tracking Status") .font(.headline) - + HStack(spacing: 20) { statusIndicator( title: "Face Detected", isActive: eyeTrackingService.faceDetected, icon: "person.fill" ) - + statusIndicator( title: "Looking Away", isActive: !eyeTrackingService.userLookingAtScreen, icon: "arrow.turn.up.right" ) } - - Text(lookingAway ? "✓ Break compliance detected" : "⚠️ Please look away from screen") - .font(.caption) - .foregroundColor(lookingAway ? .green : .orange) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 4) + + Text( + lookingAway + ? "✓ Break compliance detected" : "⚠️ Please look away from screen" + ) + .font(.caption) + .foregroundColor(lookingAway ? .green : .orange) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) } } } - + private var cameraStatusView: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text("Camera Access") .font(.headline) - + if cameraService.isCameraAuthorized { Label("Authorized", systemImage: "checkmark.circle.fill") .font(.caption) @@ -181,9 +198,9 @@ struct EnforceModeSetupView: View { .foregroundColor(.secondary) } } - + Spacer() - + if !cameraService.isCameraAuthorized { Button("Request Access") { print("📷 Request Access button clicked") @@ -202,19 +219,19 @@ struct EnforceModeSetupView: View { .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) } - + private var eyeTrackingStatusView: some View { VStack(alignment: .leading, spacing: 12) { Text("Eye Tracking Status") .font(.headline) - + HStack(spacing: 20) { statusIndicator( title: "Face Detected", isActive: eyeTrackingService.faceDetected, icon: "person.fill" ) - + statusIndicator( title: "Looking Away", isActive: !eyeTrackingService.userLookingAtScreen, @@ -225,13 +242,13 @@ struct EnforceModeSetupView: View { .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) } - + private var cameraPendingView: some View { HStack { Image(systemName: "timer") .font(.title2) .foregroundColor(.orange) - + VStack(alignment: .leading, spacing: 4) { Text("Camera Ready") .font(.headline) @@ -239,19 +256,19 @@ struct EnforceModeSetupView: View { .font(.caption) .foregroundColor(.secondary) } - + Spacer() } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) } - + private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View { VStack(spacing: 8) { Image(systemName: icon) .font(.title2) .foregroundColor(isActive ? .green : .secondary) - + Text(title) .font(.caption) .foregroundColor(.secondary) @@ -259,7 +276,7 @@ struct EnforceModeSetupView: View { } .frame(maxWidth: .infinity) } - + private var privacyInfoView: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -269,7 +286,7 @@ struct EnforceModeSetupView: View { Text("Privacy Information") .font(.headline) } - + VStack(alignment: .leading, spacing: 8) { privacyBullet("All processing happens on-device") privacyBullet("No images are stored or transmitted") @@ -281,9 +298,10 @@ struct EnforceModeSetupView: View { .foregroundColor(.secondary) } .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 { HStack(alignment: .top, spacing: 8) { Image(systemName: "checkmark") @@ -292,19 +310,19 @@ struct EnforceModeSetupView: View { Text(text) } } - + private func handleEnforceModeToggle(enabled: Bool) { print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)") isProcessingToggle = true - + Task { @MainActor in defer { isProcessingToggle = false } - + if enabled { print("🎛️ Enabling enforce mode...") await enforceModeService.enableEnforceMode() print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)") - + if !enforceModeService.isEnforceModeEnabled { print("⚠️ Failed to activate, reverting toggle") settingsManager.settings.enforcementMode = false