diff --git a/EyeBreak/EyeBreakApp.swift b/EyeBreak/EyeBreakApp.swift index f56433a..b3cda31 100644 --- a/EyeBreak/EyeBreakApp.swift +++ b/EyeBreak/EyeBreakApp.swift @@ -78,7 +78,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApp.setActivationPolicy(.accessory) // Verify status bar is still visible after mode change - if let bar = self.statusBar, let item = bar.statusItem { + if let bar = self.statusBar, let _ = bar.statusItem { } } diff --git a/EyeBreak/Managers/BreakTimerManager.swift b/EyeBreak/Managers/BreakTimerManager.swift index d8d82dc..1d5041d 100644 --- a/EyeBreak/Managers/BreakTimerManager.swift +++ b/EyeBreak/Managers/BreakTimerManager.swift @@ -8,6 +8,9 @@ import Foundation import Combine import AppKit +import OSLog + +private let log = Logger(subsystem: "com.eyebreak.app", category: "BreakTimerManager") /// Manages the core timer logic for work/break cycles class BreakTimerManager: ObservableObject { @@ -26,6 +29,7 @@ class BreakTimerManager: ObservableObject { private var timer: Timer? private var remainingSeconds: Int = 0 private var idleDetector: IdleDetector? + private var presentationDetector: PresentationDetector? private var cancellables = Set() private var wasWorkingBeforePause = false private var isForcedBreak = false // Flag to bypass Smart Schedule during forced breaks @@ -34,6 +38,7 @@ class BreakTimerManager: ObservableObject { private init() { setupIdleDetection() + setupPresentationDetection() setupWorkspaceNotifications() } @@ -124,8 +129,9 @@ class BreakTimerManager: ObservableObject { } /// Pause the timer - func pause() { + func pause(reason: String = "manual") { guard state.isActive else { return } + log.info("Timer paused — reason: \(reason), state: \(String(describing: self.state))") let wasWorking: Bool switch state { @@ -144,8 +150,9 @@ class BreakTimerManager: ObservableObject { } /// Resume from paused state - func resume() { + func resume(reason: String = "manual") { guard case .paused(let wasWorking, let seconds) = state else { return } + log.info("Timer resumed — reason: \(reason)") remainingSeconds = seconds if wasWorking { @@ -175,6 +182,11 @@ class BreakTimerManager: ObservableObject { if settings.idleDetectionEnabled { idleDetector?.start() } + + // Start presentation detection if either flag is enabled + if settings.pauseWhenSharing || settings.pauseWhenWatchingMedia { + presentationDetector?.start() + } } private func tick() { @@ -302,15 +314,33 @@ class BreakTimerManager: ObservableObject { if isIdle && self.state.isActive { // User went idle, pause timer - self.pause() + self.pause(reason: "idle") NotificationManager.shared.sendIdlePausedNotification() } else if !isIdle, case .paused = self.state { // User returned, resume timer - self.resume() + self.resume(reason: "idle ended") } } } + // MARK: - Presentation Detection Setup + + private func setupPresentationDetection() { + presentationDetector = PresentationDetector() + + presentationDetector?.onPresentationStateChanged = { [weak self] isPresenting in + guard let self = self else { return } + + if isPresenting && self.state.isActive { + // User started presenting — pause the timer + self.pause(reason: "presenting") + } else if !isPresenting, case .paused = self.state { + // Presentation ended — resume the timer + self.resume(reason: "presentation ended") + } + } + } + // MARK: - Screen Lock and Sleep Handling /// Sets up system notifications to automatically pause/resume timer during sleep and screen lock @@ -318,14 +348,14 @@ class BreakTimerManager: ObservableObject { // Mac sleep events NotificationCenter.default.publisher(for: NSWorkspace.willSleepNotification) .sink { [weak self] _ in - self?.pause() + self?.pause(reason: "system sleep") } .store(in: &cancellables) - + NotificationCenter.default.publisher(for: NSWorkspace.didWakeNotification) .sink { [weak self] _ in if case .paused = self?.state { - self?.resume() + self?.resume(reason: "system wake") } } .store(in: &cancellables) @@ -338,35 +368,35 @@ class BreakTimerManager: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.pause() + self?.pause(reason: "screen locked") } - + notificationCenter.addObserver( forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main ) { [weak self] _ in if case .paused = self?.state { - self?.resume() + self?.resume(reason: "screen unlocked") } } - + // Screen saver events (treated same as screen lock) notificationCenter.addObserver( forName: NSNotification.Name("com.apple.screensaver.didstart"), object: nil, queue: .main ) { [weak self] _ in - self?.pause() + self?.pause(reason: "screensaver started") } - + notificationCenter.addObserver( forName: NSNotification.Name("com.apple.screensaver.didstop"), object: nil, queue: .main ) { [weak self] _ in if case .paused = self?.state { - self?.resume() + self?.resume(reason: "screensaver stopped") } } } diff --git a/EyeBreak/Managers/IdleDetector.swift b/EyeBreak/Managers/IdleDetector.swift index 49ca8d1..7db1fbf 100644 --- a/EyeBreak/Managers/IdleDetector.swift +++ b/EyeBreak/Managers/IdleDetector.swift @@ -78,7 +78,7 @@ class IdleDetector { if let property = property?.takeRetainedValue() { var idleNanos: Int64 = 0 - CFNumberGetValue(property as! CFNumber, .sInt64Type, &idleNanos) + CFNumberGetValue((property as! CFNumber), .sInt64Type, &idleNanos) idleTime = TimeInterval(idleNanos) / TimeInterval(NSEC_PER_SEC) } } diff --git a/EyeBreak/Managers/LaunchAtLoginManager.swift b/EyeBreak/Managers/LaunchAtLoginManager.swift index ced5bb1..47117f1 100644 --- a/EyeBreak/Managers/LaunchAtLoginManager.swift +++ b/EyeBreak/Managers/LaunchAtLoginManager.swift @@ -1,4 +1,4 @@ - // +// // LaunchAtLoginManager.swift // EyeBreak // diff --git a/EyeBreak/Managers/NotificationManager.swift b/EyeBreak/Managers/NotificationManager.swift index 915efef..cca2030 100644 --- a/EyeBreak/Managers/NotificationManager.swift +++ b/EyeBreak/Managers/NotificationManager.swift @@ -23,13 +23,7 @@ class NotificationManager: NSObject { // MARK: - Authorization func requestAuthorization() { - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error = error { - } - - if granted { - } else { - } + center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } @@ -56,10 +50,7 @@ class NotificationManager: NSObject { trigger: nil ) - center.add(request) { error in - if let error = error { - } - } + center.add(request) { _ in } } func sendBreakStartNotification() { @@ -74,10 +65,7 @@ class NotificationManager: NSObject { trigger: nil ) - center.add(request) { error in - if let error = error { - } - } + center.add(request) { _ in } } func sendBreakCompleteNotification() { @@ -92,10 +80,7 @@ class NotificationManager: NSObject { trigger: nil ) - center.add(request) { error in - if let error = error { - } - } + center.add(request) { _ in } } func sendIdlePausedNotification() { @@ -110,10 +95,7 @@ class NotificationManager: NSObject { trigger: nil ) - center.add(request) { error in - if let error = error { - } - } + center.add(request) { _ in } } func cancelAllNotifications() { diff --git a/EyeBreak/Managers/PresentationDetector.swift b/EyeBreak/Managers/PresentationDetector.swift new file mode 100644 index 0000000..86f42fe --- /dev/null +++ b/EyeBreak/Managers/PresentationDetector.swift @@ -0,0 +1,195 @@ +// +// PresentationDetector.swift +// EyeBreak +// +// Created on March 18, 2026. +// + +import Foundation +import AppKit +import CoreGraphics +import IOKit.pwr_mgt +import OSLog + +private let log = Logger(subsystem: "com.eyebreak.app", category: "PresentationDetector") + +/// Detects when the user is sharing their screen (pauseWhenSharing) +/// or actively playing media that prevents display sleep (pauseWhenWatchingMedia). +class PresentationDetector { + + // MARK: - Properties + + private var timer: Timer? + private var isPresenting = false + + var onPresentationStateChanged: ((Bool) -> Void)? + + // Conferencing apps that create high-level overlay windows when screen-sharing + private static let conferencingBundleIds: Set = [ + "com.microsoft.teams2", // Microsoft Teams (new) + "com.microsoft.teams", // Microsoft Teams (classic) + "us.zoom.xos", // Zoom + "com.cisco.webexmeetings", // Cisco WebEx + "com.loom.desktop", // Loom + "com.slack.slackApp", // Slack + ] + + // MARK: - Lifecycle + + deinit { + stop() + } + + // MARK: - Public Methods + + func start() { + stop() + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.checkPresentationState() + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + // MARK: - Private Methods + + private func checkPresentationState() { + let presenting = detectShouldPause() + if presenting != isPresenting { + isPresenting = presenting + log.info("Presentation state changed: \(presenting ? "started" : "ended")") + onPresentationStateChanged?(isPresenting) + } + } + + private func detectShouldPause() -> Bool { + let settings = AppSettings.shared + + if settings.pauseWhenSharing { + if isDisplayMirrored() { + log.info("Pause triggered: display mirroring active") + return true + } + if hasScreenShareIndicatorWindow() { + log.info("Pause triggered: screen-share indicator window found") + return true + } + } + + if settings.pauseWhenWatchingMedia { + if isDisplaySleepPrevented() { + log.info("Pause triggered: display sleep prevented (media playing)") + return true + } + } + + return false + } + + // MARK: - Screen Sharing Detection + + /// Returns true when any display is part of a mirror set (e.g. projector connected) + private func isDisplayMirrored() -> Bool { + for screen in NSScreen.screens { + guard let displayID = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? CGDirectDisplayID else { continue } + + if CGDisplayIsInMirrorSet(displayID) != 0 { + return true + } + } + return false + } + + /// Returns true when a conferencing app has a high-level indicator window on screen. + /// Screen-sharing toolbars and capture borders from Teams, Zoom, etc. appear at + /// elevated window levels (above normal app windows at level 0). + private func hasScreenShareIndicatorWindow() -> Bool { + let runningApps = NSWorkspace.shared.runningApplications + let conferencingApps = runningApps.filter { + guard let bundleId = $0.bundleIdentifier else { return false } + return Self.conferencingBundleIds.contains(bundleId) + } + guard !conferencingApps.isEmpty else { return false } + + let conferencingPIDs = Set(conferencingApps.map { $0.processIdentifier }) + + guard let windowList = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { return false } + + // macOS window layers: normal=0, floating=3, status=25, pop-up menus=101. + // Regular meeting UI (floating bars, PiP) sits at 3–25. + // Screen-share indicator borders (the red/orange overlay) are at 500+. + let indicatorWindowLayer = 500 + + for window in windowList { + guard + let ownerPID = window[kCGWindowOwnerPID as String] as? Int32, + conferencingPIDs.contains(ownerPID), + let layer = window[kCGWindowLayer as String] as? Int, + layer >= indicatorWindowLayer + else { continue } + + let ownerName = window[kCGWindowOwnerName as String] as? String ?? "unknown" + let windowName = window[kCGWindowName as String] as? String ?? "" + log.info("Share indicator matched — app: \(ownerName), layer: \(layer), window: \(windowName)") + return true + } + + return false + } + + // MARK: - Media Playback Detection + + /// Returns true when any non-conferencing process holds a "PreventUserIdleDisplaySleep" + /// IOKit power assertion — the standard mechanism used by video players (AVFoundation, + /// VLC, browsers playing fullscreen video) to keep the display awake during playback. + /// Conferencing apps are excluded because their assertions indicate a call is active, + /// not media playback; screen sharing from those apps is caught by hasScreenShareIndicatorWindow(). + private func isDisplaySleepPrevented() -> Bool { + var assertionsRef: Unmanaged? + guard IOPMCopyAssertionsByProcess(&assertionsRef) == kIOReturnSuccess, + let dict = assertionsRef?.takeRetainedValue() as NSDictionary? else { + return false + } + + // PIDs of conferencing apps — exclude them from media detection + let runningApps = NSWorkspace.shared.runningApplications + let conferencingPIDs: Set = Set( + runningApps.compactMap { app -> Int32? in + guard let id = app.bundleIdentifier, + Self.conferencingBundleIds.contains(id) else { return nil } + return app.processIdentifier + } + ) + + for (key, value) in dict { + guard + let pid = (key as? NSNumber).map({ Int32($0.intValue) }), + !conferencingPIDs.contains(pid), + let assertionList = value as? [[String: Any]] + else { continue } + + for assertion in assertionList { + // Use string literals to avoid Swift/C bridging issues with IOKit constants + guard + let type = assertion["AssertionType"] as? String, + let level = assertion["AssertionLevel"] as? Int, + level > 0, + type == "PreventUserIdleDisplaySleep" + else { continue } + + let appName = runningApps.first(where: { $0.processIdentifier == pid })?.localizedName ?? "PID \(pid)" + log.info("Media assertion matched — app: \(appName)") + return true + } + } + return false + } +} diff --git a/EyeBreak/Managers/ScreenBlurManager.swift b/EyeBreak/Managers/ScreenBlurManager.swift index 8d2876d..1302d89 100644 --- a/EyeBreak/Managers/ScreenBlurManager.swift +++ b/EyeBreak/Managers/ScreenBlurManager.swift @@ -15,7 +15,9 @@ class ScreenBlurManager { private var overlayWindows: [NSWindow] = [] private var hostingControllers: [NSHostingController] = [] - private let windowQueue = DispatchQueue(label: "com.eyebreak.window", qos: .userInteractive) + private var globalEscMonitor: Any? + private var skipCallback: (() -> Void)? + private var workspaceObserver: Any? enum OverlayStyle { case blur @@ -41,40 +43,74 @@ class ScreenBlurManager { private func showOverlayOnMainThread(duration: Int, style: OverlayStyle, onSkip: @escaping () -> Void) { // Generate a new random color theme for this break overlay (if using random color theme) AppSettings.shared.regenerateBreakOverlayRandomTheme() - + // Close existing windows for window in self.overlayWindows { window.orderOut(nil) } self.overlayWindows.removeAll() self.hostingControllers.removeAll() - - + // Get the screen with mouse cursor (the active screen user is on) let mouseLocation = NSEvent.mouseLocation let activeScreen = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) ?? NSScreen.main ?? NSScreen.screens[0] - - - // Create overlay ONLY for the active screen where user is working + + // Create overlay for the active screen where user is working let window = self.createOverlayWindow(for: activeScreen) - + // CRITICAL: Force window frame to the active screen window.setFrame(activeScreen.frame, display: true, animate: false) - - // Create the beautiful SwiftUI overlay view + + // Shared skip callback — ensures main-thread execution and is wired into both + // the window-level sendEvent override (handles first-click even when not key) + // and the SwiftUI view (handles tap gestures / timer expiry). + let skipAction: () -> Void = { + if Thread.isMainThread { + onSkip() + } else { + DispatchQueue.main.async { onSkip() } + } + } + window.onSkip = skipAction + self.skipCallback = skipAction + + // Global ESC monitor — fires when the overlay window is not key (e.g. after + // alt-tab). Managed here on the class rather than inside the SwiftUI view so + // its lifetime is guaranteed to match the overlay's lifetime exactly. + if let existing = globalEscMonitor { NSEvent.removeMonitor(existing) } + globalEscMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard event.keyCode == 53 else { return } // ESC + DispatchQueue.main.async { self?.skipCallback?() } + } + + // Workspace observer — reclaims key-window focus after the user releases + // Cmd+Tab and another app becomes active. This is the reliable path for + // ESC to work after app switching, because resignKey() cannot reclaim while + // the Dock (app switcher) still owns the keyboard. + if let existing = workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(existing) + } + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, !self.overlayWindows.isEmpty else { return } + let bundleId = (notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication)?.bundleIdentifier ?? "" + // Ignore our own activation and the Dock (app-switcher) taking focus. + guard bundleId != "com.apple.dock", + bundleId != Bundle.main.bundleIdentifier else { return } + // Another app settled as frontmost — reclaim key status so ESC works. + NSApp.activate(ignoringOtherApps: true) + self.overlayWindows.first?.makeKey() + } + + // Create the SwiftUI overlay view let overlayView = BreakOverlayView( duration: duration, style: style, - onSkip: { [weak self] in - // Ensure onSkip is called on main thread safely - if Thread.isMainThread { - onSkip() - } else { - DispatchQueue.main.async { - onSkip() - } - } - } + onSkip: skipAction ) let hostingController = NSHostingController(rootView: overlayView) @@ -82,11 +118,12 @@ class ScreenBlurManager { window.contentView = hostingController.view - // CRITICAL: Show window WITHOUT activating the app - // This prevents desktop switching but still shows the overlay + // Show window and claim key status so ESC/click-to-skip work immediately. + // orderFrontRegardless avoids space-switching; makeKey claims keyboard focus + // without fully activating the app. window.orderFrontRegardless() - - + window.makeKey() + self.overlayWindows.append(window) self.hostingControllers.append(hostingController) @@ -105,18 +142,27 @@ class ScreenBlurManager { } private func hideOverlayOnMainThread() { - - // First, remove content views to break retain cycles + + // Remove global ESC monitor first + if let monitor = globalEscMonitor { + NSEvent.removeMonitor(monitor) + globalEscMonitor = nil + } + skipCallback = nil + + // Remove workspace observer + if let observer = workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + workspaceObserver = nil + } + + // Remove content views (breaks retain cycles), hide, then close for window in self.overlayWindows { window.contentView = nil window.orderOut(nil) - } - - // Then close windows - for window in self.overlayWindows { window.close() } - + // Clear arrays self.overlayWindows.removeAll() self.hostingControllers.removeAll() @@ -124,7 +170,7 @@ class ScreenBlurManager { // MARK: - Private Methods - private func createOverlayWindow(for screen: NSScreen) -> NSWindow { + private func createOverlayWindow(for screen: NSScreen) -> BreakOverlayWindow { let window = BreakOverlayWindow( contentRect: screen.frame, styleMask: [.borderless, .fullSizeContentView], @@ -133,17 +179,16 @@ class ScreenBlurManager { screen: screen ) - window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) // Highest possible level - above everything - window.backgroundColor = .clear // Clear background for blur effect - window.isOpaque = false // Allow transparency + window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) + window.backgroundColor = .clear + window.isOpaque = false window.hasShadow = false window.ignoresMouseEvents = false - // CRITICAL: Use .canJoinAllSpaces to show on ALL desktops simultaneously + // .canJoinAllSpaces keeps the overlay visible across all Spaces/desktops. window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient] window.acceptsMouseMovedEvents = true window.isReleasedWhenClosed = false window.animationBehavior = .none - window.alphaValue = 1.0 // Full opacity window.hidesOnDeactivate = false window.canHide = false @@ -153,14 +198,59 @@ class ScreenBlurManager { // MARK: - Custom Window Class -/// Custom NSWindow that can become key window even when borderless +/// Custom NSWindow that can become key window even when borderless. +/// Handles skip via sendEvent so the first click always dismisses even when not key. class BreakOverlayWindow: NSWindow { - override var canBecomeKey: Bool { - return true + var onSkip: (() -> Void)? + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + /// Intercept mouse clicks and ESC at the window level, before macOS key-window + /// logic runs. This means the very first click on a non-key overlay triggers skip + /// immediately instead of just activating the window (the classic "double-click" bug). + /// The skip is deferred one run-loop tick to avoid closing the window from within + /// its own sendEvent (reentrancy). + override func sendEvent(_ event: NSEvent) { + switch event.type { + case .leftMouseDown, .rightMouseDown: + DispatchQueue.main.async { [weak self] in self?.onSkip?() } + return + case .keyDown where event.keyCode == 53: // ESC + DispatchQueue.main.async { [weak self] in self?.onSkip?() } + return + case .cursorUpdate: + // Force arrow cursor — prevents SwiftUI Text views from showing the I-beam. + NSCursor.arrow.set() + return + default: + super.sendEvent(event) + } } - - override var canBecomeMain: Bool { - return true + // Reclaim key + active status when another app steals focus. + // A stacking guard ensures only one reclaim is pending at a time, so rapid + // Cmd+Tab presses don't queue up a pile of competing makeKey() calls. + private var keyReclaimPending = false + + override func resignKey() { + super.resignKey() + guard !keyReclaimPending else { return } + keyReclaimPending = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.keyReclaimPending = false + guard self.isVisible else { return } + // While the Dock's app-switcher is open the Dock owns the keyboard. + // Attempting to steal focus causes visual glitches and breaks switcher + // navigation; the workspace observer handles reclaim once the user + // releases Cmd+Tab and a real app settles as frontmost. + let frontmost = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" + guard frontmost != "com.apple.dock" else { return } + // Activating the app is required — makeKey() alone does not route + // keyboard events to a window whose application is not the active one. + NSApp.activate(ignoringOtherApps: true) + self.makeKey() + } } } diff --git a/EyeBreak/Models/Settings.swift b/EyeBreak/Models/Settings.swift index 991bf93..53969ea 100644 --- a/EyeBreak/Models/Settings.swift +++ b/EyeBreak/Models/Settings.swift @@ -25,6 +25,8 @@ class AppSettings: ObservableObject { @AppStorage("sessionType") private var sessionTypeRaw: String = SessionType.standard.rawValue @AppStorage("idleDetectionEnabled") var idleDetectionEnabled: Bool = true @AppStorage("idleThresholdMinutes") var idleThresholdMinutes: Int = 5 + @AppStorage("pauseWhenSharing") var pauseWhenSharing: Bool = true + @AppStorage("pauseWhenWatchingMedia") var pauseWhenWatchingMedia: Bool = true @AppStorage("launchAtLogin") var launchAtLogin: Bool = false @AppStorage("hasLaunchedBefore") var hasLaunchedBefore: Bool = false @AppStorage("autoStartTimer") var autoStartTimer: Bool = true // Auto-start timer when app launches diff --git a/EyeBreak/Views/BreakOverlayView.swift b/EyeBreak/Views/BreakOverlayView.swift index c4cd53e..c4c50d4 100644 --- a/EyeBreak/Views/BreakOverlayView.swift +++ b/EyeBreak/Views/BreakOverlayView.swift @@ -79,7 +79,7 @@ struct BreakOverlayView: View { startTimer() isMessageFocused = true - // Add ESC key monitoring safely + // Local monitor: catches key events when our window is key eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [onSkip] event in if event.keyCode == 53 { // ESC key DispatchQueue.main.async { @@ -89,10 +89,10 @@ struct BreakOverlayView: View { } return event } + } .onDisappear { stopTimer() - // Remove event monitor to prevent leaks if let monitor = eventMonitor { NSEvent.removeMonitor(monitor) eventMonitor = nil diff --git a/EyeBreak/Views/MenuBarView.swift b/EyeBreak/Views/MenuBarView.swift index 2db9882..6e69707 100644 --- a/EyeBreak/Views/MenuBarView.swift +++ b/EyeBreak/Views/MenuBarView.swift @@ -7,6 +7,17 @@ import SwiftUI +// Applies .bounce.byLayer symbolEffect only on macOS 15+, falls back to no-op on 14. +private struct BounceRepeatingModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 15.0, *) { + content.symbolEffect(.bounce.byLayer, options: .repeating) + } else { + content + } + } +} + struct MenuBarView: View { @EnvironmentObject var timerManager: BreakTimerManager @EnvironmentObject var settings: AppSettings @@ -234,7 +245,7 @@ struct MenuBarView: View { endPoint: .bottomTrailing ) ) - .symbolEffect(.bounce.byLayer, options: .repeating) + .modifier(BounceRepeatingModifier()) Text("\(seconds)s until break") .font(.system(.title3, design: .monospaced)) @@ -353,7 +364,7 @@ struct MenuBarView: View { .professionalButtonStyle(color: .green) } } else if case .paused = timerManager.state { - Button(action: timerManager.resume) { + Button(action: { timerManager.resume() }) { HStack { Image(systemName: "play.circle.fill") .font(.title3) diff --git a/EyeBreak/Views/SettingsView.swift b/EyeBreak/Views/SettingsView.swift index cc82064..9c8597f 100644 --- a/EyeBreak/Views/SettingsView.swift +++ b/EyeBreak/Views/SettingsView.swift @@ -423,7 +423,7 @@ struct GeneralSettingsView: View { Toggle("Enable Sound Effects", isOn: $settings.soundEnabled) Toggle("Idle Detection", isOn: $settings.idleDetectionEnabled) - + if settings.idleDetectionEnabled { Picker("Idle Threshold", selection: $settings.idleThresholdMinutes) { Text("3 minutes").tag(3) @@ -432,6 +432,12 @@ struct GeneralSettingsView: View { Text("15 minutes").tag(15) } } + + Toggle("Pause When Screen Sharing", isOn: $settings.pauseWhenSharing) + .help("Automatically pause reminders while sharing your screen in Teams, Zoom, Slack, etc.") + + Toggle("Pause When Watching Media", isOn: $settings.pauseWhenWatchingMedia) + .help("Automatically pause reminders when a video player is keeping the display awake (YouTube, Netflix, VLC, etc.)") } header: { SectionHeaderView(title: "General", icon: "gearshape.fill", color: .gray) }