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

View File

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

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
@State private var isProcessingToggle = false
@State private var isTestModeActive = false
var body: some View {
VStack(spacing: 0) {
@@ -40,7 +41,7 @@ struct EnforceModeSetupView: View {
VStack(alignment: .leading, spacing: 4) {
Text("Enable Enforce Mode")
.font(.headline)
Text("Uses camera to detect when you look away from screen")
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -70,13 +71,23 @@ struct EnforceModeSetupView: View {
cameraStatusView
if enforceModeService.isEnforceModeActive {
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
} else {
if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
} else if enforceModeService.isEnforceModeEnabled {
cameraPendingView
}
privacyInfoView
}
}
}
Spacer()
}
@@ -85,6 +96,71 @@ struct EnforceModeSetupView: View {
.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 {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -140,15 +216,9 @@ struct EnforceModeSetupView: View {
)
statusIndicator(
title: "Looking at Screen",
isActive: eyeTrackingService.userLookingAtScreen,
icon: "eye.fill"
)
statusIndicator(
title: "Eyes Closed",
isActive: eyeTrackingService.isEyesClosed,
icon: "eye.slash.fill"
title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right"
)
}
}
@@ -156,6 +226,26 @@ struct EnforceModeSetupView: View {
.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 {
VStack(spacing: 8) {
Image(systemName: icon)
@@ -183,7 +273,8 @@ struct EnforceModeSetupView: View {
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
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")
}
.font(.caption)
@@ -212,9 +303,9 @@ struct EnforceModeSetupView: View {
if enabled {
print("🎛️ Enabling enforce mode...")
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")
settingsManager.settings.enforcementMode = false
}

View File

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