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 timerManager = TimerManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var statsManager = StatsManager.shared @ObservedObject var statsManager = StatsManager.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@Default(.showCpuGraph) var showCpuGraph @Default(.showCpuGraph) var showCpuGraph
@ -68,6 +69,10 @@ struct ContentView: View {
@State private var isHoverStateChanging: Bool = false @State private var isHoverStateChanging: Bool = false
@State private var isStatsTransitioning: Bool = false @State private var isStatsTransitioning: Bool = false
@State private var isSwitchingToStats: 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 lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero @State private var gestureProgress: CGFloat = .zero
@ -166,6 +171,15 @@ struct ContentView: View {
} }
}) })
.onChange(of: vm.notchState) { _, newState in .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 // Reset hover state when notch state changes
if newState == .closed && isHovering { if newState == .closed && isHovering {
// Only reset visually, without triggering the hover logic again // Only reset visually, without triggering the hover logic again
@ -212,32 +226,89 @@ struct ContentView: View {
} }
} }
.onChange(of: coordinator.currentView) { oldValue, newValue in .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() 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 // Set flags for transition tracking
isSwitchingToStats = (oldValue != .stats && newValue == .stats) 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 { if newValue == .stats {
isStatsTransitioning = true 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 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 isStatsTransitioning = false
isSwitchingToStats = 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 // Also provide brief protection when switching FROM stats to prevent premature closure
if oldValue == .stats && newValue != .stats { if oldValue == .stats && newValue != .stats {
isStatsTransitioning = true isStatsTransitioning = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { let workItem = DispatchWorkItem {
isStatsTransitioning = false 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) .sensoryFeedback(.alignment, trigger: haptics)
@ -261,10 +332,32 @@ struct ContentView: View {
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top)
.animation(.easeInOut(duration: 0.4), value: dynamicNotchSize) .animation(.easeInOut(duration: 0.4), value: dynamicNotchSize)
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .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) .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
.background(dragDetector) .background(dragDetector)
.environmentObject(vm) .environmentObject(vm)
.environmentObject(webcamManager) .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 @ViewBuilder
@ -310,6 +403,8 @@ struct ContentView: View {
MusicLiveActivity() MusicLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity() 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 { } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
} else if vm.notchState == .open { } else if vm.notchState == .open {
@ -491,7 +586,7 @@ struct ContentView: View {
} }
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} }
@ViewBuilder @ViewBuilder
var dragDetector: some View { var dragDetector: some View {
if Defaults[.dynamicShelf] { if Defaults[.dynamicShelf] {
@ -519,6 +614,7 @@ struct ContentView: View {
} }
} }
// MARK: - Private Methods
private func doOpen() { private func doOpen() {
// Switch to timer tab if timer is active // Switch to timer tab if timer is active
if timerManager.isTimerActive { if timerManager.isTimerActive {
@ -623,7 +719,8 @@ struct ContentView: View {
} }
// Apply stronger protection when switching to stats, especially with 4+ graphs // 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 return // Skip closing during transitions, but allow visual updates
} }
@ -634,12 +731,12 @@ struct ContentView: View {
let isStatsTab = coordinator.currentView == .stats let isStatsTab = coordinator.currentView == .stats
let hasExpandedHeight = isStatsTab && statsTabHasExpandedHeight() 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 let delay: Double
if hasExpandedHeight { 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 { } else if isStatsTab {
delay = 0.6 // Medium for stats with 1-3 graphs delay = 1.0 // Long for stats with 1-3 graphs
} else { } else {
delay = 0.2 // Short for other tabs delay = 0.2 // Short for other tabs
} }

View file

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

View file

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

View file

@ -135,6 +135,10 @@
}, },
"Active" : { "Active" : {
},
"Active - No Recording" : {
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
"isCommentAutoGenerated" : true
}, },
"Add" : { "Add" : {
@ -476,6 +480,10 @@
}, },
"Description" : { "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" : { "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." : { "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" : { "Progress bar" : {
"localizations" : { "localizations" : {
@ -1146,6 +1158,10 @@
}, },
"Recording" : { "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" : { "Recording Status" : {
@ -1199,6 +1215,10 @@
}, },
"Screen Assistant:" : { "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" : { "Screenshot options" : {

View file

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

View file

@ -155,6 +155,13 @@ struct DynamicIslandHeader: View {
} }
.buttonStyle(PlainButtonStyle()) .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] { if Defaults[.showBatteryIndicator] {
DynamicIslandBatteryView( DynamicIslandBatteryView(
batteryWidth: 30, batteryWidth: 30,

View file

@ -337,9 +337,7 @@ struct NotchStatsView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { .onAppear {
if enableStatsFeature && Defaults[.autoStartStatsMonitoring] && !statsManager.isMonitoring { // Note: Smart monitoring will handle starting/stopping based on notch state and current view
statsManager.startMonitoring()
}
// Protect against hover interference during view transition // Protect against hover interference during view transition
isResizingForStats = true isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 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 } @State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName }
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@Default(.mirrorShape) var mirrorShape @Default(.mirrorShape) var mirrorShape
@Default(.showEmojis) var showEmojis @Default(.showEmojis) var showEmojis
@ -162,6 +163,8 @@ struct GeneralSettings: View {
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
@Default(.enableGestures) var enableGestures @Default(.enableGestures) var enableGestures
@Default(.openNotchOnHover) var openNotchOnHover @Default(.openNotchOnHover) var openNotchOnHover
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
@Default(.showRecordingIndicator) var showRecordingIndicator
var body: some View { var body: some View {
Form { Form {
@ -192,6 +195,36 @@ struct GeneralSettings: View {
} header: { } header: {
Text("System features") 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 { Section {
Picker(selection: $notchHeightMode, label: Picker(selection: $notchHeightMode, label:
@ -1586,11 +1619,10 @@ struct StatsSettings: View {
Section { Section {
Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature) Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature)
.onChange(of: enableStatsFeature) { _, newValue in .onChange(of: enableStatsFeature) { _, newValue in
if newValue { if !newValue {
statsManager.startMonitoring()
} else {
statsManager.stopMonitoring() statsManager.stopMonitoring()
} }
// Note: Smart monitoring will handle starting when switching to stats tab
} }
if enableStatsFeature { if enableStatsFeature {

View file

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

View file

@ -46,30 +46,7 @@ struct TimerLiveActivity: View {
private var timerInfoSection: some View { private var timerInfoSection: some View {
Rectangle() Rectangle()
.fill(.black) .fill(.black)
.overlay( .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
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))
} }
private var timerCountdownSection: some View { 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] = [] @Published var diskWriteHistory: [Double] = []
private var monitoringTimer: Timer? private var monitoringTimer: Timer?
private var delayedStopTimer: Timer?
private var delayedStartTimer: Timer?
private let maxHistoryPoints = 30 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 // Network monitoring state
private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0) private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0)
private var previousTimestamp: Date = Date() private var previousTimestamp: Date = Date()
@ -70,12 +77,51 @@ class StatsManager: ObservableObject {
deinit { deinit {
stopMonitoring() 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() { func startMonitoring() {
guard !isMonitoring else { return } guard !isMonitoring else { return }
print("StatsManager: Starting monitoring...")
// Reset baseline for accurate measurement // Reset baseline for accurate measurement
let initialStats = getNetworkStats() let initialStats = getNetworkStats()
previousNetworkStats = initialStats previousNetworkStats = initialStats
@ -96,14 +142,21 @@ class StatsManager: ObservableObject {
self.updateSystemStats() self.updateSystemStats()
} }
} }
print("StatsManager: Monitoring started")
} }
func stopMonitoring() { func stopMonitoring() {
guard isMonitoring else { return } guard isMonitoring else { return }
// Clean up all timers
monitoringTimer?.invalidate() monitoringTimer?.invalidate()
monitoringTimer = nil monitoringTimer = nil
delayedStartTimer?.invalidate()
delayedStopTimer?.invalidate()
isMonitoring = false isMonitoring = false
print("StatsManager: Monitoring stopped")
} }
// MARK: - Private Methods // MARK: - Private Methods

View file

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

View file

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