Still working on the janky animation

This commit is contained in:
Hariharan Mudaliar 2025-10-02 23:18:58 +05:30
commit c5186c47f8
10 changed files with 628 additions and 1 deletions

View file

@ -22,6 +22,7 @@ struct ContentView: View {
@ObservedObject var timerManager = TimerManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var statsManager = StatsManager.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.showCpuGraph) var showCpuGraph
@ -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 {

View file

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

View file

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

View file

@ -135,6 +135,10 @@
},
"Active" : {
},
"Active - No Recording" : {
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
"isCommentAutoGenerated" : true
},
"Add" : {
@ -476,6 +480,10 @@
},
"Description" : {
},
"Detection Status" : {
"comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.",
"isCommentAutoGenerated" : true
},
"Disable" : {
@ -1094,6 +1102,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" : {

View file

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

View file

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

View file

@ -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:

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,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<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)
} 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)
}
}

View file

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