feat: preview video feed

This commit is contained in:
Michael Freno
2026-01-14 00:49:40 -05:00
parent 9bd45a0c6b
commit 780da2951b
6 changed files with 261 additions and 55 deletions

View File

@@ -12,8 +12,10 @@ import Foundation
class EnforceModeService: ObservableObject { class EnforceModeService: ObservableObject {
static let shared = EnforceModeService() static let shared = EnforceModeService()
@Published var isEnforceModeActive = false @Published var isEnforceModeEnabled = false
@Published var isCameraActive = false
@Published var userCompliedWithBreak = false @Published var userCompliedWithBreak = false
@Published var isTestMode = false
private var settingsManager: SettingsManager private var settingsManager: SettingsManager
private var eyeTrackingService: EyeTrackingService private var eyeTrackingService: EyeTrackingService
@@ -37,27 +39,36 @@ class EnforceModeService: ObservableObject {
func enableEnforceMode() async { func enableEnforceMode() async {
print("🔒 enableEnforceMode called") print("🔒 enableEnforceMode called")
guard !isEnforceModeActive else { guard !isEnforceModeEnabled else {
print("⚠️ Enforce mode already active") print("⚠️ Enforce mode already enabled")
return return
} }
do { let cameraService = CameraAccessService.shared
print("🔒 Starting eye tracking...") if !cameraService.isCameraAuthorized {
try await eyeTrackingService.startEyeTracking() do {
isEnforceModeActive = true print("🔒 Requesting camera permission...")
print("✓ Enforce mode enabled") try await cameraService.requestCameraAccess()
} catch { } catch {
print("⚠️ Failed to enable enforce mode: \(error.localizedDescription)") print("⚠️ Failed to get camera permission: \(error.localizedDescription)")
isEnforceModeActive = false return
}
} }
guard cameraService.isCameraAuthorized else {
print("❌ Camera permission denied")
return
}
isEnforceModeEnabled = true
print("✓ Enforce mode enabled (camera will activate before lookaway reminders)")
} }
func disableEnforceMode() { func disableEnforceMode() {
guard isEnforceModeActive else { return } guard isEnforceModeEnabled else { return }
eyeTrackingService.stopEyeTracking() stopCamera()
isEnforceModeActive = false isEnforceModeEnabled = false
userCompliedWithBreak = false userCompliedWithBreak = false
print("✓ Enforce mode disabled") print("✓ Enforce mode disabled")
} }
@@ -67,7 +78,7 @@ class EnforceModeService: ObservableObject {
} }
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool { func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforceModeActive else { return false } guard isEnforceModeEnabled else { return false }
guard settingsManager.settings.enforcementMode else { return false } guard settingsManager.settings.enforcementMode else { return false }
switch timerIdentifier { switch timerIdentifier {
@@ -78,8 +89,32 @@ class EnforceModeService: ObservableObject {
} }
} }
func startCameraForLookawayTimer(secondsRemaining: Int) async {
guard isEnforceModeEnabled else { return }
guard !isCameraActive else { return }
print("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
do {
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
print("✓ Camera active")
} catch {
print("⚠️ Failed to start camera: \(error.localizedDescription)")
}
}
func stopCamera() {
guard isCameraActive else { return }
print("👁️ Stopping camera")
eyeTrackingService.stopEyeTracking()
isCameraActive = false
userCompliedWithBreak = false
}
func checkUserCompliance() { func checkUserCompliance() {
guard isEnforceModeActive else { guard isCameraActive else {
userCompliedWithBreak = false userCompliedWithBreak = false
return return
} }
@@ -89,22 +124,37 @@ class EnforceModeService: ObservableObject {
} }
private func handleGazeChange(lookingAtScreen: Bool) { private func handleGazeChange(lookingAtScreen: Bool) {
guard isEnforceModeActive else { return } guard isCameraActive else { return }
checkUserCompliance() checkUserCompliance()
} }
func startEnforcementForActiveReminder() { func handleReminderDismissed() {
guard let engine = timerEngine else { return } stopCamera()
guard let activeReminder = engine.activeReminder else { return } }
func startTestMode() async {
guard isEnforceModeEnabled else { return }
guard !isCameraActive else { return }
switch activeReminder { print("🧪 Starting test mode")
case .lookAwayTriggered: isTestMode = true
if shouldEnforceBreak(for: .builtIn(.lookAway)) {
checkUserCompliance() do {
} try await eyeTrackingService.startEyeTracking()
default: isCameraActive = true
break print("✓ Test mode camera active")
} catch {
print("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
isTestMode = false
} }
} }
func stopTestMode() {
guard isTestMode else { return }
print("🧪 Stopping test mode")
stopCamera()
isTestMode = false
}
} }

View File

@@ -22,6 +22,13 @@ class EyeTrackingService: NSObject, ObservableObject {
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)
var previewLayer: AVCaptureVideoPreviewLayer? {
guard let session = captureSession else { return nil }
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
return layer
}
private override init() { private override init() {
super.init() super.init()
} }

View File

@@ -80,10 +80,7 @@ class TimerEngine: ObservableObject {
/// Check if enforce mode is active and should affect timer behavior /// Check if enforce mode is active and should affect timer behavior
func checkEnforceMode() { func checkEnforceMode() {
guard let enforceService = enforceModeService else { return } // Deprecated - camera is now activated in handleTick before timer triggers
guard enforceService.isEnforceModeActive else { return }
enforceService.startEnforcementForActiveReminder()
} }
private func updateConfigurations() { private func updateConfigurations() {
@@ -210,10 +207,11 @@ class TimerEngine: ObservableObject {
guard let reminder = activeReminder else { return } guard let reminder = activeReminder else { return }
activeReminder = nil activeReminder = nil
// Skip to next interval and resume the timer that was paused
let identifier = reminder.identifier let identifier = reminder.identifier
skipNext(identifier: identifier) skipNext(identifier: identifier)
resumeTimer(identifier: identifier) resumeTimer(identifier: identifier)
enforceModeService?.handleReminderDismissed()
} }
private func handleTick() { private func handleTick() {
@@ -228,13 +226,23 @@ class TimerEngine: ObservableObject {
timerStates[identifier]?.remainingSeconds -= 1 timerStates[identifier]?.remainingSeconds -= 1
if let updatedState = timerStates[identifier], updatedState.remainingSeconds <= 0 { if let updatedState = timerStates[identifier] {
triggerReminder(for: identifier) if updatedState.remainingSeconds <= 3 && !updatedState.isPaused {
break if case .builtIn(.lookAway) = identifier {
if enforceModeService?.shouldEnforceBreak(for: identifier) == true {
Task { @MainActor in
await enforceModeService?.startCameraForLookawayTimer(secondsRemaining: updatedState.remainingSeconds)
}
}
}
}
if updatedState.remainingSeconds <= 0 {
triggerReminder(for: identifier)
break
}
} }
} }
checkEnforceMode()
} }
func triggerReminder(for identifier: TimerIdentifier) { func triggerReminder(for identifier: TimerIdentifier) {

View File

@@ -0,0 +1,48 @@
//
// CameraPreviewView.swift
// Gaze
//
// Created by Mike Freno on 1/14/26.
//
import SwiftUI
import AVFoundation
struct CameraPreviewView: NSViewRepresentable {
let previewLayer: AVCaptureVideoPreviewLayer
let borderColor: NSColor
func makeNSView(context: Context) -> NSView {
let view = PreviewContainerView()
view.wantsLayer = true
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
updateBorder(view: nsView, color: borderColor)
}
private func updateBorder(view: NSView, color: NSColor) {
view.layer?.borderColor = color.cgColor
view.layer?.borderWidth = 4
view.layer?.cornerRadius = 12
view.layer?.masksToBounds = true
}
class PreviewContainerView: NSView {
override func layout() {
super.layout()
// Update sublayer frames when view is resized
if let previewLayer = layer?.sublayers?.first as? AVCaptureVideoPreviewLayer {
previewLayer.frame = bounds
}
}
}
}

View File

@@ -14,6 +14,7 @@ struct EnforceModeSetupView: View {
@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
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -40,7 +41,7 @@ struct EnforceModeSetupView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Enable Enforce Mode") Text("Enable Enforce Mode")
.font(.headline) .font(.headline)
Text("Uses camera to detect when you look away from screen") Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -70,11 +71,21 @@ struct EnforceModeSetupView: View {
cameraStatusView cameraStatusView
if enforceModeService.isEnforceModeActive { if enforceModeService.isEnforceModeEnabled {
eyeTrackingStatusView testModeButton
} }
privacyInfoView if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
} else {
if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
} else if enforceModeService.isEnforceModeEnabled {
cameraPendingView
}
privacyInfoView
}
} }
} }
@@ -85,6 +96,71 @@ struct EnforceModeSetupView: View {
.background(.clear) .background(.clear)
} }
private var testModeButton: some View {
Button(action: {
Task { @MainActor in
if isTestModeActive {
enforceModeService.stopTestMode()
isTestModeActive = false
} else {
await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive
}
}
}) {
HStack {
Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill")
.font(.title3)
Text(isTestModeActive ? "Stop Test" : "Test Tracking")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
}
.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)
.frame(height: 300)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
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)
}
.padding()
.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) {
@@ -140,15 +216,9 @@ struct EnforceModeSetupView: View {
) )
statusIndicator( statusIndicator(
title: "Looking at Screen", title: "Looking Away",
isActive: eyeTrackingService.userLookingAtScreen, isActive: !eyeTrackingService.userLookingAtScreen,
icon: "eye.fill" icon: "arrow.turn.up.right"
)
statusIndicator(
title: "Eyes Closed",
isActive: eyeTrackingService.isEyesClosed,
icon: "eye.slash.fill"
) )
} }
} }
@@ -156,6 +226,26 @@ struct EnforceModeSetupView: View {
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .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)
Text("Will activate 3 seconds before lookaway reminder")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding()
.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)
@@ -183,7 +273,8 @@ struct EnforceModeSetupView: View {
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")
privacyBullet("Camera only active during lookaway reminders") privacyBullet("Camera only active during lookaway reminders (3 second window)")
privacyBullet("Eyes closed does not affect countdown")
privacyBullet("You can disable at any time") privacyBullet("You can disable at any time")
} }
.font(.caption) .font(.caption)
@@ -212,9 +303,9 @@ struct EnforceModeSetupView: View {
if enabled { if enabled {
print("🎛️ Enabling enforce mode...") print("🎛️ Enabling enforce mode...")
await enforceModeService.enableEnforceMode() await enforceModeService.enableEnforceMode()
print("🎛️ Enforce mode enabled, isActive: \(enforceModeService.isEnforceModeActive)") print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)")
if !enforceModeService.isEnforceModeActive { if !enforceModeService.isEnforceModeEnabled {
print("⚠️ Failed to activate, reverting toggle") print("⚠️ Failed to activate, reverting toggle")
settingsManager.settings.enforcementMode = false settingsManager.settings.enforcementMode = false
} }

View File

@@ -25,14 +25,16 @@ final class EnforceModeServiceTests: XCTestCase {
func testEnforceModeServiceInitialization() { func testEnforceModeServiceInitialization() {
XCTAssertNotNil(enforceModeService) XCTAssertNotNil(enforceModeService)
XCTAssertFalse(enforceModeService.isEnforceModeActive) XCTAssertFalse(enforceModeService.isEnforceModeEnabled)
XCTAssertFalse(enforceModeService.isCameraActive)
XCTAssertFalse(enforceModeService.userCompliedWithBreak) XCTAssertFalse(enforceModeService.userCompliedWithBreak)
} }
func testDisableEnforceModeResetsState() { func testDisableEnforceModeResetsState() {
enforceModeService.disableEnforceMode() enforceModeService.disableEnforceMode()
XCTAssertFalse(enforceModeService.isEnforceModeActive) XCTAssertFalse(enforceModeService.isEnforceModeEnabled)
XCTAssertFalse(enforceModeService.isCameraActive)
XCTAssertFalse(enforceModeService.userCompliedWithBreak) XCTAssertFalse(enforceModeService.userCompliedWithBreak)
} }