From 44569d36b568f50abd9381f1d216cceb8c7196ba Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 2 Oct 2025 21:02:52 +0530 Subject: [PATCH 1/4] I can't fix it --- DynamicIsland/ContentView.swift | 114 ++++++++++++++++-- .../components/Notch/NotchStatsView.swift | 4 +- .../components/Settings/SettingsView.swift | 5 +- .../components/Stats/StatsPanelView.swift | 4 +- DynamicIsland/managers/StatsManager.swift | 55 ++++++++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index ff95e6b..372f6bd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -68,6 +68,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 +170,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 +225,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 +331,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 @@ -519,6 +611,7 @@ struct ContentView: View { } } + // MARK: - Private Methods private func doOpen() { // Switch to timer tab if timer is active if timerManager.isTimerActive { @@ -623,7 +716,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 +728,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 } diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 3b695dd..b8e172e 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -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) { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 0650c7a..07fd21c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1586,11 +1586,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 { diff --git a/DynamicIsland/components/Stats/StatsPanelView.swift b/DynamicIsland/components/Stats/StatsPanelView.swift index 2bcd4f2..2c86b47 100644 --- a/DynamicIsland/components/Stats/StatsPanelView.swift +++ b/DynamicIsland/components/Stats/StatsPanelView.swift @@ -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 } } } diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 4391cdf..493dfeb 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -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 From c5186c47f8712bfd1daa8717bdee5b4e2c791410 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 2 Oct 2025 23:18:58 +0530 Subject: [PATCH 2/4] Still working on the janky animation --- DynamicIsland/ContentView.swift | 51 ++- DynamicIsland/DynamicIslandApp.swift | 14 + .../DynamicIslandViewCoordinator.swift | 1 + DynamicIsland/Localizable.xcstrings | 24 ++ .../LiveActivityModifier.swift | 1 + .../Notch/DynamicIslandHeader.swift | 7 + .../components/Settings/SettingsView.swift | 33 ++ .../components/UI/RecordingIndicator.swift | 172 ++++++++++ .../managers/ScreenRecordingManager.swift | 322 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 4 + 10 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/UI/RecordingIndicator.swift create mode 100644 DynamicIsland/managers/ScreenRecordingManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 372f6bd..bc4ec20 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -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 @@ -75,6 +76,7 @@ struct ContentView: View { @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero + @State private var isPulsing: Bool = false @State private var haptics: Bool = false @@ -402,6 +404,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 { @@ -558,7 +562,7 @@ struct ContentView: View { } } ) - .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) + .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer || coordinator.expandingView.type == .recording) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) HStack { @@ -583,6 +587,51 @@ struct ContentView: View { } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } + + func RecordingLiveActivity() -> some View { + HStack { + // Left side - Recording icon with record.circle (same pattern as album art) + HStack { + ZStack { + // Background circle matching notch style + RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed) + .fill(Color.red.opacity(0.1)) + + // Circle.fill icon with pulsing animation + Image(systemName: "circle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.red) + .scaleEffect(isPulsing ? 1.2 : 1.0) + .opacity(isPulsing ? 0.7 : 1.0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + .onDisappear { + isPulsing = false + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + + // Center - Recording indicator (no expansion needed since no text) + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right side - Empty (as requested) + HStack { + // Empty space + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + .animation(.easeInOut(duration: 0.3), value: isPulsing) + } @ViewBuilder var dragDetector: some View { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 444a14d..ad27d3c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -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, diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 6147995..b8275fe 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -19,6 +19,7 @@ enum SneakContentType { case battery case download case timer + case recording } struct sneakPeek { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 655bb42..caed19b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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,14 @@ }, "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 + }, + "Privacy Alert" : { + "comment" : "A label displayed below the recording icon, indicating privacy settings.", + "isCommentAutoGenerated" : true }, "Progress bar" : { "localizations" : { @@ -1146,6 +1162,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 +1219,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" : { diff --git a/DynamicIsland/components/Live activities/LiveActivityModifier.swift b/DynamicIsland/components/Live activities/LiveActivityModifier.swift index 051b779..3af0829 100644 --- a/DynamicIsland/components/Live activities/LiveActivityModifier.swift +++ b/DynamicIsland/components/Live activities/LiveActivityModifier.swift @@ -11,6 +11,7 @@ enum ActivityType { case mediaPlayback case charging case download + case recording } struct LiveActivityModifier: ViewModifier { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 2e6fea2..c0c0af2 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -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, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 07fd21c..518b32c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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: diff --git a/DynamicIsland/components/UI/RecordingIndicator.swift b/DynamicIsland/components/UI/RecordingIndicator.swift new file mode 100644 index 0000000..9b3edaa --- /dev/null +++ b/DynamicIsland/components/UI/RecordingIndicator.swift @@ -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) + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/ScreenRecordingManager.swift b/DynamicIsland/managers/ScreenRecordingManager.swift new file mode 100644 index 0000000..1082f45 --- /dev/null +++ b/DynamicIsland/managers/ScreenRecordingManager.swift @@ -0,0 +1,322 @@ +// +// 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() + private var recordingStartTime: Date? + private var durationTimer: Timer? + private var debounceIdleTask: Task? + + // 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) + } else if !confirmedState && isRecording { + // Stopped recording - let expanding view auto-collapse naturally (like music) + lastUpdated = Date() + stopDurationTracking() + updateIdleState(recording: false) + } + + 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) + } +} \ No newline at end of file diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c30515d..b4481f1 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -343,6 +343,10 @@ extension Defaults.Keys { static let enableKeyboardBacklightHUD = Key("enableKeyboardBacklightHUD", default: true) static let systemHUDSensitivity = Key("systemHUDSensitivity", default: 5) + // MARK: Screen Recording Detection Feature + static let enableScreenRecordingDetection = Key("enableScreenRecordingDetection", default: true) + static let showRecordingIndicator = Key("showRecordingIndicator", default: true) + // Helper to determine the default media controller based on macOS version static var defaultMediaController: MediaControllerType { if #available(macOS 15.4, *) { From 6eba9eff0b1348df918746c5f645ebbab24e71d0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 3 Oct 2025 11:52:18 +0530 Subject: [PATCH 3/4] Janky animation --- DynamicIsland/ContentView.swift | 76 ++++++++++++------- DynamicIsland/Localizable.xcstrings | 4 - .../components/Timer/TimerLiveActivity.swift | 25 +----- .../managers/ScreenRecordingManager.swift | 9 ++- DynamicIsland/managers/TimerManager.swift | 12 ++- 5 files changed, 66 insertions(+), 60 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index bc4ec20..bbbcb2e 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -76,7 +76,6 @@ struct ContentView: View { @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero - @State private var isPulsing: Bool = false @State private var haptics: Bool = false @@ -562,7 +561,7 @@ struct ContentView: View { } } ) - .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer || coordinator.expandingView.type == .recording) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) + .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) HStack { @@ -590,47 +589,52 @@ struct ContentView: View { func RecordingLiveActivity() -> some View { HStack { - // Left side - Recording icon with record.circle (same pattern as album art) + // Left side - Recording icon (EXACT same structure as music album art) HStack { - ZStack { - // Background circle matching notch style - RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed) - .fill(Color.red.opacity(0.1)) - - // Circle.fill icon with pulsing animation - Image(systemName: "circle.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.red) - .scaleEffect(isPulsing ? 1.2 : 1.0) - .opacity(isPulsing ? 0.7 : 1.0) - } - .onAppear { - withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - isPulsing = true - } - } - .onDisappear { - isPulsing = false - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + ZStack { + RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed) + .fill(Color.red.opacity(0.1)) + + Circle() + .fill(Color.red) + .frame(width: 12, height: 12) + .modifier(PulsingModifier()) + } + ) + .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) - // Center - Recording indicator (no expansion needed since no text) + // Center - Fixed width (SAME as music) Rectangle() .fill(.black) .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) - // Right side - Empty (as requested) + // Right side - Empty space to balance (SAME structure as music visualizer) HStack { - // Empty space + // Empty - no content } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - .animation(.easeInOut(duration: 0.3), value: isPulsing) + } + + // Helper function to format recording duration + private func formatDuration(_ duration: TimeInterval) -> String { + let hours = Int(duration) / 3600 + let minutes = Int(duration) / 60 % 60 + let seconds = Int(duration) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%d:%02d", minutes, seconds) + } } @ViewBuilder @@ -903,3 +907,19 @@ struct FullScreenDropDelegate: DropDelegate { return 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 + } + } + } +} diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index caed19b..89f9ac4 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1107,10 +1107,6 @@ "comment" : "The title of the privacy and security settings screen.", "isCommentAutoGenerated" : true }, - "Privacy Alert" : { - "comment" : "A label displayed below the recording icon, indicating privacy settings.", - "isCommentAutoGenerated" : true - }, "Progress bar" : { "localizations" : { "en" : { diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index 3f7a559..9e5aff2 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -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 { diff --git a/DynamicIsland/managers/ScreenRecordingManager.swift b/DynamicIsland/managers/ScreenRecordingManager.swift index 1082f45..bac90ba 100644 --- a/DynamicIsland/managers/ScreenRecordingManager.swift +++ b/DynamicIsland/managers/ScreenRecordingManager.swift @@ -141,14 +141,21 @@ class ScreenRecordingManager: ObservableObject { 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 } - isRecording = confirmedState print("ScreenRecordingManager: ✅ Recording state changed to \(confirmedState)") } else { print("ScreenRecordingManager: ❌ State not confirmed, keeping \(isRecording)") diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift index 0921a9d..06adfd9 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -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 From 2e1a143d19a11d902a1ea9b72ee3a4d83295d2de Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 3 Oct 2025 12:19:51 +0530 Subject: [PATCH 4/4] Fixed Animation --- DynamicIsland/ContentView.swift | 66 --------------- .../Recording/RecordingLiveActivity.swift | 84 +++++++++++++++++++ 2 files changed, 84 insertions(+), 66 deletions(-) create mode 100644 DynamicIsland/components/Recording/RecordingLiveActivity.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index bbbcb2e..66be883 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -587,56 +587,6 @@ struct ContentView: View { .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } - func RecordingLiveActivity() -> some View { - HStack { - // Left side - Recording icon (EXACT same structure as music album art) - HStack { - Color.clear - .aspectRatio(1, contentMode: .fit) - .background( - ZStack { - RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed) - .fill(Color.red.opacity(0.1)) - - Circle() - .fill(Color.red) - .frame(width: 12, height: 12) - .modifier(PulsingModifier()) - } - ) - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) - - // Center - Fixed width (SAME as music) - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) - - // Right side - Empty space to balance (SAME structure as music visualizer) - HStack { - // Empty - no content - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) - } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } - - // Helper function to format recording duration - private func formatDuration(_ duration: TimeInterval) -> String { - let hours = Int(duration) / 3600 - let minutes = Int(duration) / 60 % 60 - let seconds = Int(duration) % 60 - - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } else { - return String(format: "%d:%02d", minutes, seconds) - } - } - @ViewBuilder var dragDetector: some View { if Defaults[.dynamicShelf] { @@ -907,19 +857,3 @@ struct FullScreenDropDelegate: DropDelegate { return 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 - } - } - } -} diff --git a/DynamicIsland/components/Recording/RecordingLiveActivity.swift b/DynamicIsland/components/Recording/RecordingLiveActivity.swift new file mode 100644 index 0000000..6c4edd3 --- /dev/null +++ b/DynamicIsland/components/Recording/RecordingLiveActivity.swift @@ -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 + } + } + } +}