Merge pull request #29 from Ebullioscopic/feature/screenrecord

Feature/screenrecord
This commit is contained in:
Hariharan Mudaliar 2025-10-03 12:49:20 +05:30 • committed by GitHub
commit 3947573312
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 841 additions and 48 deletions

View file

@ -22,6 +22,7 @@ struct ContentView: View {
@ObservedObject var timerManager = TimerManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var statsManager = StatsManager.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.showCpuGraph) var showCpuGraph
@ -68,6 +69,10 @@ struct ContentView: View {
@State private var isHoverStateChanging: Bool = false
@State private var isStatsTransitioning: Bool = false
@State private var isSwitchingToStats: Bool = false
@State private var isViewTransitioning: Bool = false
@State private var statsTransitionWorkItem: DispatchWorkItem?
@State private var viewTransitionWorkItem: DispatchWorkItem?
@State private var sizeChangeWorkItem: DispatchWorkItem?
@State private var lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero
@ -166,6 +171,15 @@ struct ContentView: View {
}
})
.onChange(of: vm.notchState) { _, newState in
// Update smart monitoring based on notch state
if enableStatsFeature {
let currentViewString = coordinator.currentView == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: newState == .open,
currentView: currentViewString
)
}
// Reset hover state when notch state changes
if newState == .closed && isHovering {
// Only reset visually, without triggering the hover logic again
@ -212,32 +226,89 @@ struct ContentView: View {
}
}
.onChange(of: coordinator.currentView) { oldValue, newValue in
// Cancel any pending close actions during view transitions
// Update smart monitoring based on current view change
if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: vm.notchState == .open,
currentView: currentViewString
)
}
// Cancel any pending close actions and ALL existing transition timers during view transitions
hoverWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
// Reset all transition flags immediately to prevent conflicts
isStatsTransitioning = false
isSwitchingToStats = false
isViewTransitioning = false
// Check if this transition will cause a size change
let oldSize = oldValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: openNotchSize.width, height: openNotchSize.height + 65) : openNotchSize
let newSize = newValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: openNotchSize.width, height: openNotchSize.height + 65) : openNotchSize
let sizeWillChange = oldSize != newSize
// Set flags for transition tracking
isSwitchingToStats = (oldValue != .stats && newValue == .stats)
// Provide protection when switching to stats tab
// Set view transition flag if size will change with proper cleanup
if sizeWillChange {
isViewTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
// Provide protection when switching to stats tab with proper cleanup
if newValue == .stats {
isStatsTransitioning = true
// Longer delay if stats tab has expanded height (4+ graphs)
// Much longer delay if stats tab has expanded height (4+ graphs)
let hasExpandedHeight = statsTabHasExpandedHeight()
let protectionDelay: Double = hasExpandedHeight ? 1.5 : 0.8
let protectionDelay: Double = hasExpandedHeight ? 4.0 : 2.0 // Much longer delays
DispatchQueue.main.asyncAfter(deadline: .now() + protectionDelay) {
let workItem = DispatchWorkItem {
isStatsTransitioning = false
isSwitchingToStats = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + protectionDelay, execute: workItem)
}
// Also provide brief protection when switching FROM stats to prevent premature closure
if oldValue == .stats && newValue != .stats {
isStatsTransitioning = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
let workItem = DispatchWorkItem {
isStatsTransitioning = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
}
}
.onChange(of: [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph]) { _, _ in
// Protect during graph count changes that might affect size
if coordinator.currentView == .stats {
// Cancel existing transition timers to prevent conflicts
viewTransitionWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
isViewTransitioning = true
isStatsTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
isStatsTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
}
.sensoryFeedback(.alignment, trigger: haptics)
@ -261,10 +332,32 @@ struct ContentView: View {
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top)
.animation(.easeInOut(duration: 0.4), value: dynamicNotchSize)
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
.onChange(of: dynamicNotchSize) { oldSize, newSize in
// Protect against hover interference during frame size changes
if oldSize != newSize {
// Cancel existing size change timer to prevent conflicts
sizeChangeWorkItem?.cancel()
isViewTransitioning = true
let workItem = DispatchWorkItem {
isViewTransitioning = false
}
sizeChangeWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) // Frame animation + buffer
}
}
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
.background(dragDetector)
.environmentObject(vm)
.environmentObject(webcamManager)
.onDisappear {
// Clean up all timer work items to prevent memory leaks and conflicts
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
}
}
@ViewBuilder
@ -310,6 +403,8 @@ struct ContentView: View {
MusicLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed {
RecordingLiveActivity()
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
} else if vm.notchState == .open {
@ -491,7 +586,7 @@ struct ContentView: View {
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
}
@ViewBuilder
var dragDetector: some View {
if Defaults[.dynamicShelf] {
@ -519,6 +614,7 @@ struct ContentView: View {
}
}
// MARK: - Private Methods
private func doOpen() {
// Switch to timer tab if timer is active
if timerManager.isTimerActive {
@ -623,7 +719,8 @@ struct ContentView: View {
}
// Apply stronger protection when switching to stats, especially with 4+ graphs
if isStatsTransitioning || (isSwitchingToStats && statsTabHasExpandedHeight()) {
// Also protect during any size change animations
if isStatsTransitioning || (isSwitchingToStats && statsTabHasExpandedHeight()) || isViewTransitioning {
return // Skip closing during transitions, but allow visual updates
}
@ -634,12 +731,12 @@ struct ContentView: View {
let isStatsTab = coordinator.currentView == .stats
let hasExpandedHeight = isStatsTab && statsTabHasExpandedHeight()
// Longer delays for stats tab, especially when it has expanded height
// Much longer delays for stats tab, especially when it has expanded height
let delay: Double
if hasExpandedHeight {
delay = 1.0 // Extra long for stats with 4+ graphs
delay = 2.0 // Very long for stats with 4+ graphs
} else if isStatsTab {
delay = 0.6 // Medium for stats with 1-3 graphs
delay = 1.0 // Long for stats with 1-3 graphs
} else {
delay = 0.2 // Short for other tabs
}

View file

@ -384,6 +384,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Setup SystemHUD Manager
SystemHUDManager.shared.setup(coordinator: coordinator)
// Setup ScreenRecording Manager
if Defaults[.enableScreenRecordingDetection] {
ScreenRecordingManager.shared.startMonitoring()
}
// Setup media key monitoring
setupMediaKeyMonitoring()
@ -416,6 +421,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
// Observe screen recording settings changes
Defaults.publisher(.enableScreenRecordingDetection, options: []).sink { _ in
if Defaults[.enableScreenRecordingDetection] {
ScreenRecordingManager.shared.startMonitoring()
} else {
ScreenRecordingManager.shared.stopMonitoring()
}
}.store(in: &cancellables)
NotificationCenter.default.addObserver(
self,

View file

@ -19,6 +19,7 @@ enum SneakContentType {
case battery
case download
case timer
case recording
}
struct sneakPeek {

View file

@ -135,6 +135,10 @@
},
"Active" : {
},
"Active - No Recording" : {
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
"isCommentAutoGenerated" : true
},
"Add" : {
@ -476,6 +480,10 @@
},
"Description" : {
},
"Detection Status" : {
"comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.",
"isCommentAutoGenerated" : true
},
"Disable" : {
@ -1094,6 +1102,10 @@
},
"Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : {
},
"Privacy & Security" : {
"comment" : "The title of the privacy and security settings screen.",
"isCommentAutoGenerated" : true
},
"Progress bar" : {
"localizations" : {
@ -1146,6 +1158,10 @@
},
"Recording" : {
},
"Recording Detected" : {
"comment" : "A text description of a red dot displayed in the Dynamic Island header when screen recording is active.",
"isCommentAutoGenerated" : true
},
"Recording Status" : {
@ -1199,6 +1215,10 @@
},
"Screen Assistant:" : {
},
"Screen recording detection shows a red indicator when your screen is being captured. Detection uses minimal system resources and can be disabled at any time." : {
"comment" : "A footer description for the \"Privacy & Security\" section of the settings.",
"isCommentAutoGenerated" : true
},
"Screenshot options" : {

View file

@ -11,6 +11,7 @@ enum ActivityType {
case mediaPlayback
case charging
case download
case recording
}
struct LiveActivityModifier<Left: View, Right: View>: ViewModifier {

View file

@ -155,6 +155,13 @@ struct DynamicIslandHeader: View {
}
.buttonStyle(PlainButtonStyle())
}
// Screen Recording Indicator
if Defaults[.enableScreenRecordingDetection] && Defaults[.showRecordingIndicator] {
RecordingIndicator()
.frame(width: 30, height: 30) // Same size as other header elements
}
if Defaults[.showBatteryIndicator] {
DynamicIslandBatteryView(
batteryWidth: 30,

View file

@ -337,9 +337,7 @@ struct NotchStatsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
if enableStatsFeature && Defaults[.autoStartStatsMonitoring] && !statsManager.isMonitoring {
statsManager.startMonitoring()
}
// Note: Smart monitoring will handle starting/stopping based on notch state and current view
// Protect against hover interference during view transition
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {

View file

@ -0,0 +1,84 @@
//
// RecordingLiveActivity.swift
// DynamicIsland
//
// Created for screen recording live activity
//
import SwiftUI
import Defaults
struct RecordingLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@State private var isHovering: Bool = false
@State private var gestureProgress: CGFloat = 0
@State private var isExpanded: Bool = false
var body: some View {
HStack(spacing: 0) {
// Left - Red circle with animation
Color.clear
.background {
if isExpanded {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(Color.red.opacity(0.15))
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
.modifier(PulsingModifier())
}
.frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
// Center - Black fill
Rectangle()
.fill(.black)
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
// Right - Empty for symmetry with animation
Color.clear
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0))
.onAppear {
withAnimation(.smooth(duration: 0.4)) {
isExpanded = true
}
}
.onChange(of: recordingManager.isRecording) { _, newValue in
if !newValue {
withAnimation(.smooth(duration: 0.4)) {
isExpanded = false
}
} else {
withAnimation(.smooth(duration: 0.4)) {
isExpanded = true
}
}
}
}
}
// Pulsing animation modifier for recording indicator
struct PulsingModifier: ViewModifier {
@State private var isPulsing = false
func body(content: Content) -> some View {
content
.scaleEffect(isPulsing ? 1.2 : 1.0)
.opacity(isPulsing ? 0.7 : 1.0)
.onAppear {
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
}
}

View file

@ -149,6 +149,7 @@ struct GeneralSettings: View {
@State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName }
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@Default(.mirrorShape) var mirrorShape
@Default(.showEmojis) var showEmojis
@ -162,6 +163,8 @@ struct GeneralSettings: View {
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
@Default(.enableGestures) var enableGestures
@Default(.openNotchOnHover) var openNotchOnHover
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
@Default(.showRecordingIndicator) var showRecordingIndicator
var body: some View {
Form {
@ -192,6 +195,36 @@ struct GeneralSettings: View {
} header: {
Text("System features")
}
Section {
Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection)
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
.disabled(!enableScreenRecordingDetection)
if recordingManager.isMonitoring {
HStack {
Text("Detection Status")
Spacer()
if recordingManager.isRecording {
HStack(spacing: 4) {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
Text("Recording Detected")
.foregroundColor(.red)
}
} else {
Text("Active - No Recording")
.foregroundColor(.green)
}
}
}
} header: {
Text("Privacy & Security")
} footer: {
Text("Screen recording detection shows a red indicator when your screen is being captured. Detection uses minimal system resources and can be disabled at any time.")
}
Section {
Picker(selection: $notchHeightMode, label:
@ -1586,11 +1619,10 @@ struct StatsSettings: View {
Section {
Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature)
.onChange(of: enableStatsFeature) { _, newValue in
if newValue {
statsManager.startMonitoring()
} else {
if !newValue {
statsManager.stopMonitoring()
}
// Note: Smart monitoring will handle starting when switching to stats tab
}
if enableStatsFeature {

View file

@ -130,9 +130,7 @@ struct StatsPanelView: View {
.cornerRadius(12)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
.onAppear {
if enableStatsFeature && !statsManager.isMonitoring {
statsManager.startMonitoring()
}
// Note: Smart monitoring will handle starting/stopping based on UI state
}
}
}

View file

@ -46,30 +46,7 @@ struct TimerLiveActivity: View {
private var timerInfoSection: some View {
Rectangle()
.fill(.black)
.overlay(
HStack(alignment: .center) {
if coordinator.expandingView.show && coordinator.expandingView.type == .timer {
// Timer name
MarqueeText(
.constant(timerManager.timerName),
textColor: timerManager.timerColor,
minDuration: 0.4,
frameWidth: 100
)
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
Spacer(minLength: vm.closedNotchSize.width)
// Timer status
Text(timerManager.isPaused ? "Paused" : "Active")
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(timerManager.isOvertime ? .red : timerManager.timerColor)
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
}
}
)
.frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0))
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
}
private var timerCountdownSection: some View {

View file

@ -0,0 +1,172 @@
//
// RecordingIndicator.swift
// DynamicIsland
//
// Created for screen recording detection feature
// Displays a red pulsing dot when screen recording is active
import SwiftUI
struct RecordingIndicator: View {
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@State private var isPulsing = false
// MARK: - Configuration
private let indicatorSize: CGFloat = 6
private let glowSize: CGFloat = 2
private let animationDuration: Double = 0.8
var body: some View {
Group {
if recordingManager.isRecording {
ZStack {
// Glow effect
Circle()
.fill(Color.red.opacity(0.3))
.frame(width: indicatorSize + glowSize * 2, height: indicatorSize + glowSize * 2)
.blur(radius: glowSize)
.scaleEffect(isPulsing ? 1.2 : 1.0)
// Main indicator dot
Circle()
.fill(Color.red)
.frame(width: indicatorSize, height: indicatorSize)
.opacity(isPulsing ? 1.0 : 0.6)
}
.onAppear {
startPulsingAnimation()
}
.onDisappear {
stopPulsingAnimation()
}
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))
}
}
.animation(.easeInOut(duration: 0.3), value: recordingManager.isRecording)
}
// MARK: - Private Methods
private func startPulsingAnimation() {
isPulsing = false
withAnimation(.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
private func stopPulsingAnimation() {
withAnimation(.easeInOut(duration: 0.2)) {
isPulsing = false
}
}
}
// MARK: - Preview
struct RecordingIndicator_Previews: PreviewProvider {
static var previews: some View {
Group {
// Recording state preview
VStack {
Text("Recording Active")
.font(.caption)
RecordingIndicator()
.onAppear {
ScreenRecordingManager.shared.isRecording = true
}
}
.padding()
.background(Color.black)
.previewDisplayName("Recording Active")
// Non-recording state preview
VStack {
Text("Not Recording")
.font(.caption)
RecordingIndicator()
.onAppear {
ScreenRecordingManager.shared.isRecording = false
}
}
.padding()
.background(Color.black)
.previewDisplayName("Not Recording")
}
}
}
// MARK: - Alternative Indicator Styles
struct RecordingIndicatorLarge: View {
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@State private var isPulsing = false
private let indicatorSize: CGFloat = 10
private let glowSize: CGFloat = 3
var body: some View {
Group {
if recordingManager.isRecording {
ZStack {
// Outer glow
Circle()
.fill(Color.red.opacity(0.2))
.frame(width: indicatorSize + glowSize * 4, height: indicatorSize + glowSize * 4)
.blur(radius: glowSize)
.scaleEffect(isPulsing ? 1.3 : 1.0)
// Inner glow
Circle()
.fill(Color.red.opacity(0.4))
.frame(width: indicatorSize + glowSize * 2, height: indicatorSize + glowSize * 2)
.blur(radius: glowSize / 2)
.scaleEffect(isPulsing ? 1.1 : 1.0)
// Main indicator
Circle()
.fill(Color.red)
.frame(width: indicatorSize, height: indicatorSize)
.opacity(isPulsing ? 1.0 : 0.7)
}
.onAppear {
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))
}
}
.animation(.easeInOut(duration: 0.3), value: recordingManager.isRecording)
}
}
// MARK: - Subtle Indicator for Closed State
struct RecordingIndicatorSubtle: View {
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@State private var opacity: Double = 0.5
var body: some View {
Group {
if recordingManager.isRecording {
Circle()
.fill(Color.red)
.frame(width: 4, height: 4)
.opacity(opacity)
.onAppear {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
opacity = 1.0
}
}
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: recordingManager.isRecording)
}
}

View file

@ -0,0 +1,329 @@
//
// ScreenRecordingManager.swift
// DynamicIsland
//
// Created for screen recording detection feature
// Monitors system for active screen recording and provides real-time status updates
import Foundation
import Combine
import ScreenCaptureKit
import AppKit
import Defaults
import SwiftUI
@MainActor
class ScreenRecordingManager: ObservableObject {
static let shared = ScreenRecordingManager()
// MARK: - Coordinator
private let coordinator = DynamicIslandViewCoordinator.shared
// MARK: - Published Properties
@Published var isRecording: Bool = false
@Published var isMonitoring: Bool = false
@Published var recordingDuration: TimeInterval = 0
@Published var isRecorderIdle: Bool = true
@Published var lastUpdated: Date = .distantPast
// MARK: - Private Properties
private var monitoringTimer: Timer?
private var lastScreensHaveSeparateSpaces: Bool = false
private var cancellables = Set<AnyCancellable>()
private var recordingStartTime: Date?
private var durationTimer: Timer?
private var debounceIdleTask: Task<Void, Never>?
// MARK: - Configuration
private let monitoringInterval: TimeInterval = 1.0 // Check every second
private let debounceDelay: TimeInterval = 0.5 // Debounce rapid changes
// MARK: - Initialization
private init() {
setupInitialState()
}
deinit {
Task { @MainActor in
stopMonitoring()
debounceIdleTask?.cancel()
}
}
// MARK: - Public Methods
/// Start monitoring for screen recording activity
func startMonitoring() {
guard !isMonitoring else {
print("ScreenRecordingManager: Already monitoring, skipping start")
return
}
isMonitoring = true
lastScreensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
print("ScreenRecordingManager: 🟢 Starting monitoring...")
print("ScreenRecordingManager: Initial screensHaveSeparateSpaces = \(lastScreensHaveSeparateSpaces)")
// Start timer-based monitoring
monitoringTimer = Timer.scheduledTimer(withTimeInterval: monitoringInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
await self?.checkRecordingStatus()
}
}
print("ScreenRecordingManager: ✅ Started monitoring with \(monitoringInterval)s interval")
}
/// Stop monitoring for screen recording activity
func stopMonitoring() {
guard isMonitoring else {
print("ScreenRecordingManager: Not monitoring, skipping stop")
return
}
print("ScreenRecordingManager: 🛑 Stopping monitoring...")
isMonitoring = false
monitoringTimer?.invalidate()
monitoringTimer = nil
// Stop duration tracking
stopDurationTracking()
// Reset recording state when stopping
if isRecording {
print("ScreenRecordingManager: Resetting isRecording from true to false")
}
isRecording = false
print("ScreenRecordingManager: ✅ Stopped monitoring")
}
/// Toggle monitoring state
func toggleMonitoring() {
if isMonitoring {
stopMonitoring()
} else {
startMonitoring()
}
}
// MARK: - Private Methods
private func setupInitialState() {
lastScreensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
}
/// Check current recording status using multiple detection methods
private func checkRecordingStatus() async {
let currentRecordingState = await detectScreenRecording()
// Debug: Always log current check
print("ScreenRecordingManager: 🔍 Checking... current=\(isRecording), detected=\(currentRecordingState)")
// Debounce changes to avoid flickering
if currentRecordingState != isRecording {
print("ScreenRecordingManager: 🔄 State change detected (\(isRecording) -> \(currentRecordingState)), debouncing...")
// Add a small delay to confirm the state change
try? await Task.sleep(nanoseconds: UInt64(debounceDelay * 1_000_000_000))
// Double-check the state after debounce
let confirmedState = await detectScreenRecording()
print("ScreenRecordingManager: 🔍 After debounce: confirmed=\(confirmedState), original=\(currentRecordingState)")
if confirmedState == currentRecordingState {
if confirmedState && !isRecording {
// Started recording
lastUpdated = Date()
startDurationTracking()
updateIdleState(recording: true)
// Trigger expanding view like music activity
coordinator.toggleExpandingView(status: true, type: .recording)
withAnimation(.smooth) {
isRecording = confirmedState
}
} else if !confirmedState && isRecording {
// Stopped recording - let expanding view auto-collapse naturally (like music)
lastUpdated = Date()
stopDurationTracking()
updateIdleState(recording: false)
withAnimation(.smooth) {
isRecording = confirmedState
}
} else {
isRecording = confirmedState
}
print("ScreenRecordingManager: ✅ Recording state changed to \(confirmedState)")
} else {
print("ScreenRecordingManager: ❌ State not confirmed, keeping \(isRecording)")
}
}
}
/// Detect screen recording using hybrid approach
private func detectScreenRecording() async -> Bool {
print("ScreenRecordingManager: 🔎 Starting detection...")
// Method 1: NSScreen.screensHaveSeparateSpaces detection
let screensHaveSeparateSpaces = NSScreen.screensHaveSeparateSpaces
let screenSpacesChanged = screensHaveSeparateSpaces != lastScreensHaveSeparateSpaces
print("ScreenRecordingManager: Method 1 - screensHaveSeparateSpaces: \(screensHaveSeparateSpaces), last: \(lastScreensHaveSeparateSpaces), changed: \(screenSpacesChanged)")
// Update last known state
lastScreensHaveSeparateSpaces = screensHaveSeparateSpaces
// Method 2: ScreenCaptureKit detection (if available)
let screencaptureKitDetection = await detectWithScreenCaptureKit()
print("ScreenRecordingManager: Method 2 - ScreenCaptureKit: \(screencaptureKitDetection)")
// Method 3: Process-based detection for common recording apps
let processBasedDetection = detectRecordingProcesses()
print("ScreenRecordingManager: Method 3 - Process detection: \(processBasedDetection)")
// Combine detection methods (any positive result indicates recording)
let isCurrentlyRecording = screenSpacesChanged || screencaptureKitDetection || processBasedDetection
print("ScreenRecordingManager: 🎯 Final result: \(isCurrentlyRecording) (spaces:\(screenSpacesChanged) || kit:\(screencaptureKitDetection) || process:\(processBasedDetection))")
return isCurrentlyRecording
}
/// Detect recording using ScreenCaptureKit (requires macOS 12.3+)
private func detectWithScreenCaptureKit() async -> Bool {
guard #available(macOS 12.3, *) else { return false }
do {
// Check if there are any active capture sessions
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
// If we can successfully get content and there are running applications with capture capabilities,
// it might indicate active screen capture
let runningApps = content.applications.filter { $0.applicationName.lowercased().contains("screen") ||
$0.applicationName.lowercased().contains("record") ||
$0.applicationName.lowercased().contains("capture") }
return !runningApps.isEmpty
} catch {
// If ScreenCaptureKit fails, fall back to other detection methods
return false
}
}
/// Detect recording by checking for known recording processes
private func detectRecordingProcesses() -> Bool {
let recordingProcessNames = [
"QuickTime Player",
"ScreenSearch",
"OBS",
"Camtasia",
"ScreenFlow",
"CleanMyMac",
"Screenshot",
"screencapture"
]
let runningApps = NSWorkspace.shared.runningApplications
print("ScreenRecordingManager: Checking \(runningApps.count) running apps for recording processes...")
for app in runningApps {
if let appName = app.localizedName {
for processName in recordingProcessNames {
if appName.lowercased().contains(processName.lowercased()) {
print("ScreenRecordingManager: 🎬 Found recording app: \(appName)")
return true
}
}
}
}
print("ScreenRecordingManager: No recording processes found")
return false
}
/// Start tracking recording duration
private func startDurationTracking() {
recordingStartTime = Date()
recordingDuration = 0
durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.updateDuration()
}
}
print("ScreenRecordingManager: ⏱️ Started duration tracking")
}
/// Stop tracking recording duration
private func stopDurationTracking() {
durationTimer?.invalidate()
durationTimer = nil
recordingStartTime = nil
// Keep the last duration for a moment before resetting
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.recordingDuration = 0
}
print("ScreenRecordingManager: ⏹️ Stopped duration tracking")
}
/// Update the current recording duration
private func updateDuration() {
guard let startTime = recordingStartTime else { return }
recordingDuration = Date().timeIntervalSince(startTime)
}
/// Copy EXACT music idle state logic
private func updateIdleState(recording: Bool) {
if recording {
isRecorderIdle = false
debounceIdleTask?.cancel()
} else {
debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Defaults[.waitInterval]))
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {
if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] {
withAnimation {
self.isRecorderIdle = !self.isRecording
}
}
}
}
}
}
}
// MARK: - Extensions
extension ScreenRecordingManager {
/// Get current recording status without async
var currentRecordingStatus: Bool {
return isRecording
}
/// Check if monitoring is available (for settings UI)
var isMonitoringAvailable: Bool {
return true // Basic monitoring is always available
}
/// Get current monitoring interval for debugging
var currentMonitoringInterval: TimeInterval {
return monitoringInterval
}
/// Get formatted recording duration string
var formattedDuration: String {
let totalSeconds = Int(recordingDuration)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
}

View file

@ -38,8 +38,15 @@ class StatsManager: ObservableObject {
@Published var diskWriteHistory: [Double] = []
private var monitoringTimer: Timer?
private var delayedStopTimer: Timer?
private var delayedStartTimer: Timer?
private let maxHistoryPoints = 30
// Smart monitoring state
private var shouldMonitorForStats: Bool = false
private var lastNotchState: String = "closed"
private var lastCurrentView: String = "other"
// Network monitoring state
private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0)
private var previousTimestamp: Date = Date()
@ -70,12 +77,51 @@ class StatsManager: ObservableObject {
deinit {
stopMonitoring()
delayedStartTimer?.invalidate()
delayedStopTimer?.invalidate()
}
// MARK: - Public Methods
// MARK: - Smart Monitoring
func updateMonitoringState(notchIsOpen: Bool, currentView: String) {
let notchState = notchIsOpen ? "open" : "closed"
// Only react to actual state changes
guard notchState != lastNotchState || currentView != lastCurrentView else { return }
lastNotchState = notchState
lastCurrentView = currentView
// Cancel any pending timers
delayedStartTimer?.invalidate()
delayedStopTimer?.invalidate()
// Determine if we should be monitoring
shouldMonitorForStats = notchIsOpen && (currentView == "stats")
if shouldMonitorForStats {
// Start monitoring after 3.5 seconds (when notch is open and stats tab is active)
delayedStartTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
self?.startMonitoring()
}
}
} else {
// Stop monitoring after 3 seconds (when notch is closed or stats tab is not active)
let delay = notchIsOpen ? 0.1 : 3.0 // Stop quickly when switching tabs, slowly when closing notch
delayedStopTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
self?.stopMonitoring()
}
}
}
}
// MARK: - Public Monitoring Controls
func startMonitoring() {
guard !isMonitoring else { return }
print("StatsManager: Starting monitoring...")
// Reset baseline for accurate measurement
let initialStats = getNetworkStats()
previousNetworkStats = initialStats
@ -96,14 +142,21 @@ class StatsManager: ObservableObject {
self.updateSystemStats()
}
}
print("StatsManager: Monitoring started")
}
func stopMonitoring() {
guard isMonitoring else { return }
// Clean up all timers
monitoringTimer?.invalidate()
monitoringTimer = nil
delayedStartTimer?.invalidate()
delayedStopTimer?.invalidate()
isMonitoring = false
print("StatsManager: Monitoring stopped")
}
// MARK: - Private Methods

View file

@ -129,7 +129,9 @@ class TimerManager: ObservableObject {
timerInstance?.invalidate()
// Start new timer
isTimerActive = true
withAnimation(.smooth) {
isTimerActive = true
}
isFinished = false
isOvertime = false
timerName = name
@ -191,7 +193,9 @@ class TimerManager: ObservableObject {
timerInstance?.invalidate()
timerInstance = nil
soundPlayer?.stop()
isTimerActive = false
withAnimation(.smooth) {
isTimerActive = false
}
resetTimer()
}
@ -236,7 +240,9 @@ class TimerManager: ObservableObject {
}
private func resetTimer() {
isTimerActive = false
withAnimation(.smooth) {
isTimerActive = false
}
timerName = "Timer"
totalDuration = 0
remainingTime = 0

View file

@ -343,6 +343,10 @@ extension Defaults.Keys {
static let enableKeyboardBacklightHUD = Key<Bool>("enableKeyboardBacklightHUD", default: true)
static let systemHUDSensitivity = Key<Int>("systemHUDSensitivity", default: 5)
// MARK: Screen Recording Detection Feature
static let enableScreenRecordingDetection = Key<Bool>("enableScreenRecordingDetection", default: true)
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
// Helper to determine the default media controller based on macOS version
static var defaultMediaController: MediaControllerType {
if #available(macOS 15.4, *) {