mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #29 from Ebullioscopic/feature/screenrecord
Feature/screenrecord
This commit is contained in:
commit
3947573312
16 changed files with 841 additions and 48 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ enum SneakContentType {
|
|||
case battery
|
||||
case download
|
||||
case timer
|
||||
case recording
|
||||
}
|
||||
|
||||
struct sneakPeek {
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ enum ActivityType {
|
|||
case mediaPlayback
|
||||
case charging
|
||||
case download
|
||||
case recording
|
||||
}
|
||||
|
||||
struct LiveActivityModifier<Left: View, Right: View>: ViewModifier {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
172
DynamicIsland/components/UI/RecordingIndicator.swift
Normal file
172
DynamicIsland/components/UI/RecordingIndicator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
329
DynamicIsland/managers/ScreenRecordingManager.swift
Normal file
329
DynamicIsland/managers/ScreenRecordingManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, *) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue