diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f9fd28..db8790fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Screen Recording HUD**: Added a recording live activity with optional native stop controls and configurable hover presentation. ### Changed ### Fixed +- Fixed Dynamic Island window pinning so it stays anchored while switching macOS Spaces. ### Removed diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1142d1e8..10f72d69 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -78,6 +78,9 @@ struct ContentView: View { @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection + @Default(.showRecordingIndicator) var showRecordingIndicator + @Default(.recordingHoverStyle) var recordingHoverStyle + @Default(.recordingControlMode) var recordingControlMode @Default(.enableCapsLockIndicator) var enableCapsLockIndicator @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities @Default(.showStandardMediaControls) var showStandardMediaControls @@ -123,6 +126,13 @@ struct ContentView: View { : 460 return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) } + + if recordingLiveActivityVisibleOnClosedNotch { + return CGSize( + width: vm.closedNotchSize.width + recordingHUDExtraWidth, + height: vm.effectiveClosedNotchHeight + recordingHUDExtraHeight + ) + } // Handle battery HUD expansion sizing if vm.notchState == .closed && @@ -187,6 +197,10 @@ struct ContentView: View { let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) { return CGSize(width: baseSize.width, height: preferredHeight) } + + if coordinator.currentView == .llmUsage { + return CGSize(width: baseSize.width, height: max(baseSize.height, llmUsageOpenNotchHeight)) + } guard coordinator.currentView == .stats else { return baseSize @@ -330,13 +344,15 @@ struct ContentView: View { } private func closedMusicPairingEligible(hasActiveMusicSnapshot: Bool) -> Bool { - vm.notchState == .closed - && hasActiveMusicSnapshot - && coordinator.musicLiveActivityEnabled - && closedMusicContentEnabled - && !vm.hideOnClosed - && !lockScreenManager.isLocked - && !isMusicHUDDeferredAfterUnlock + isClosedMusicPairingEligible( + notchState: vm.notchState, + hasActiveMusicSnapshot: hasActiveMusicSnapshot, + musicLiveActivityEnabled: coordinator.musicLiveActivityEnabled, + closedMusicContentEnabled: closedMusicContentEnabled, + hideOnClosed: vm.hideOnClosed, + isLocked: lockScreenManager.isLocked, + isDeferredAfterUnlock: isMusicHUDDeferredAfterUnlock + ) } private var closedLiveActivitySwapTransition: AnyTransition { @@ -460,6 +476,52 @@ struct ContentView: View { isCurrentScreenExpansionVisible ? coordinator.expandingView.type : nil } + private var hasActiveMusicSnapshotForClosedPairing: Bool { + if musicManager.isPlaying { return true } + + let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !musicManager.artistName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return !musicManager.isPlayerIdle && hasMusicMetadata + } + + private var recordingLiveActivityVisibleOnClosedNotch: Bool { + recordingHUDLayout.isVisible + } + + private var recordingHUDLayout: RecordingHUDLayout { + makeRecordingHUDLayout( + notchState: vm.notchState, + screenRecordingDetectionEnabled: enableScreenRecordingDetection, + showRecordingIndicator: showRecordingIndicator, + hideOnClosed: vm.hideOnClosed, + isRecording: recordingManager.isRecording, + closedMusicPairingEligible: closedMusicPairingEligible( + hasActiveMusicSnapshot: hasActiveMusicSnapshotForClosedPairing + ), + recordingControlMode: recordingControlMode, + canStopFromHUD: recordingManager.canStopFromHUD, + enableMinimalisticUI: enableMinimalisticUI, + recordingHoverStyle: recordingHoverStyle, + expanded: isHovering + ) + } + + private var recordingHUDDefaultExpandedOnHover: Bool { + recordingHUDLayout.showsDefaultExpansion + } + + private var recordingHUDInlineExpandedOnHover: Bool { + recordingHUDLayout.showsInlineExpansion + } + + private var recordingHUDExtraWidth: CGFloat { + recordingHUDLayout.extraWidth + } + + private var recordingHUDExtraHeight: CGFloat { + recordingHUDLayout.extraHeight + } + private var displayedBatteryHUDLevel: Int { let resolvedLevel = batteryModel.activeTemporaryHUDLevelOverride ?? Int(batteryModel.levelBattery.rounded()) @@ -493,6 +555,21 @@ struct ContentView: View { } } + private var activeClosedRecordingSurfaceShape: AnyShape? { + guard recordingHUDDefaultExpandedOnHover else { return nil } + + if isDynamicIslandMode { + return AnyShape(DynamicIslandPillShape(cornerRadius: dynamicIslandPillCornerRadiusInsets.opened)) + } + + return AnyShape( + NotchShape( + topCornerRadius: activeCornerRadiusInsets.closed.top, + bottomCornerRadius: 40 + ) + ) + } + private func resolvedBatteryNotificationStyle(for kind: BatteryTemporaryHUDKind) -> BatteryNotificationStyle { switch kind { case .charging: @@ -508,6 +585,9 @@ struct ContentView: View { /// Resolves the clip/content shape per-screen: pill on non-notch screens /// when dynamic island mode is active, standard notch shape otherwise. private var resolvedClipShape: AnyShape { + if let activeClosedRecordingSurfaceShape { + return activeClosedRecordingSurfaceShape + } if let activeClosedBatterySurfaceShape { return activeClosedBatterySurfaceShape } @@ -571,6 +651,7 @@ struct ContentView: View { handleHover(hovering) } .onTapGesture { + guard !recordingOpenGestureLocked else { return } if handleClosedMusicWaveformTapIfNeeded() { return } @@ -946,8 +1027,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { ReminderLiveActivity() - } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { - RecordingLiveActivity() + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && recordingManager.isRecording && Defaults[.enableScreenRecordingDetection] && Defaults[.showRecordingIndicator] && !vm.hideOnClosed && !musicPairingEligible { + RecordingLiveActivity(hoverAnimation: $isHovering, gestureProgress: $gestureProgress) } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) @@ -1313,7 +1394,7 @@ struct ContentView: View { return .reminder(reminder) } - if enableScreenRecordingDetection && (recordingManager.isRecording || !recordingManager.isRecorderIdle) { + if enableScreenRecordingDetection && showRecordingIndicator && recordingManager.isRecording { return .recording } @@ -2099,6 +2180,7 @@ struct ContentView: View { await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, + !self.recordingLiveActivityVisibleOnClosedNotch, !self.isSneakPeekVisibleOnCurrentScreen, !self.coordinator.isHoverOpenSuppressed else { return } @@ -2278,6 +2360,7 @@ struct ContentView: View { private func handleOpenScrollGesture(translation: CGFloat, phase: NSEvent.Phase) { guard vm.notchState == .closed else { return } + guard !recordingOpenGestureLocked else { return } withAnimation(.smooth) { gestureProgress = (translation / Defaults[.gestureSensitivity]) * 20 @@ -2300,6 +2383,10 @@ struct ContentView: View { } } + private var recordingOpenGestureLocked: Bool { + recordingLiveActivityVisibleOnClosedNotch + } + private func handleCloseScrollGesture(translation: CGFloat, phase: NSEvent.Phase) { guard vm.notchState == .open, !vm.isHoveringCalendar, !vm.isScrollGestureActive else { return } @@ -2632,7 +2719,7 @@ struct ContentView: View { private func hideMusicControlWindow() {} #endif - + private func shouldFixSizeForSneakPeek() -> Bool { guard isSneakPeekVisibleOnCurrentScreen else { return false } let style = resolvedSneakPeekStyle() diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 6de360ca..4c18b0e5 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -334,23 +334,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - /// Rebuilds the notch's CGSSpace membership from the current hide option and the - /// live windows. The space pins the notch above every space (fullscreen included) - /// and is used **only** for "Never hide"; the hide options keep the set empty so - /// FullscreenMediaDetector can hide the notch. Assigning the whole set lets the - /// CGSSpace diff additions/removals, so this is safe to call on any change. + /// Rebuilds the notch's CGSSpace membership from the live windows. + /// This intentionally does not gate on `hideNotchOption == .never`: Space + /// membership keeps the window anchored while switching desktops, while + /// FullscreenMediaDetector/`hideOnClosed` owns whether the closed notch renders + /// in fullscreen. @MainActor private func syncNotchSpaceMembership() { - guard Defaults[.hideNotchOption] == .never else { - NotchSpaceManager.shared.notchSpace.windows = [] - return - } + NotchSpaceManager.shared.notchSpace.windows = currentDynamicIslandWindows() + } + + private func currentDynamicIslandWindows() -> Set { if Defaults[.showOnAllDisplays] { - NotchSpaceManager.shared.notchSpace.windows = Set(windows.values) + return Set(windows.values) } else if let window = window { - NotchSpaceManager.shared.notchSpace.windows = [window] - } else { - NotchSpaceManager.shared.notchSpace.windows = [] + return [window] + } + return [] + } + + @MainActor + private func reassertDynamicIslandWindowSpacePresence() { + guard !windowsHiddenForLock else { return } + + syncNotchSpaceMembership() + + for window in currentDynamicIslandWindows() { + window.collectionBehavior = DynamicIslandWindow.pinnedCollectionBehavior + window.orderFrontRegardless() } } @@ -382,12 +393,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) window.orderFrontRegardless() - // Pin above every space (fullscreen included) only for "Never hide"; the - // hide options leave the window on the collectionBehavior path so - // FullscreenMediaDetector can hide it. See NotchSpaceManager. - if Defaults[.hideNotchOption] == .never { - NotchSpaceManager.shared.notchSpace.windows.insert(window) - } + NotchSpaceManager.shared.notchSpace.windows.insert(window) //SkyLightOperator.shared.delegateWindow(window) return window } @@ -454,6 +460,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } + if let recordingHUDSize = recordingHUDLayoutForSizing().size( + closedNotchSize: vm.closedNotchSize, + effectiveClosedNotchHeight: vm.effectiveClosedNotchHeight + ) { + return addShadowPadding(to: recordingHUDSize, isMinimalistic: Defaults[.enableMinimalisticUI]) + } + // Check for battery HUD expansion if vm.notchState == .closed && coordinator.expandingView.show && @@ -504,6 +517,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 let maxFraction = Defaults[.terminalMaxHeightFraction] baseSize.height = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) + } else if coordinator.currentView == .llmUsage { + baseSize.height = max(baseSize.height, llmUsageOpenNotchHeight) } let adjustedContentSize = statsAdjustedNotchSize( @@ -511,7 +526,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { isStatsTabActive: coordinator.currentView == .stats, secondRowProgress: coordinator.statsSecondRowExpansion ) - var result = addShadowPadding( + let result = addShadowPadding( to: adjustedContentSize, isMinimalistic: Defaults[.enableMinimalisticUI] ) @@ -519,6 +534,39 @@ class AppDelegate: NSObject, NSApplicationDelegate { return result } + private func recordingHUDLayoutForSizing() -> RecordingHUDLayout { + makeRecordingHUDLayout( + notchState: vm.notchState, + screenRecordingDetectionEnabled: Defaults[.enableScreenRecordingDetection], + showRecordingIndicator: Defaults[.showRecordingIndicator], + hideOnClosed: vm.hideOnClosed, + isRecording: ScreenRecordingManager.shared.isRecording, + closedMusicPairingEligible: closedMusicPairingEligibleForSizing(), + recordingControlMode: Defaults[.recordingControlMode], + canStopFromHUD: ScreenRecordingManager.shared.canStopFromHUD, + enableMinimalisticUI: Defaults[.enableMinimalisticUI], + recordingHoverStyle: Defaults[.recordingHoverStyle], + expanded: true + ) + } + + private func closedMusicPairingEligibleForSizing() -> Bool { + let musicManager = MusicManager.shared + let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !musicManager.artistName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasActiveMusicSnapshot = musicManager.isPlaying || (!musicManager.isPlayerIdle && hasMusicMetadata) + + return isClosedMusicPairingEligible( + notchState: vm.notchState, + hasActiveMusicSnapshot: hasActiveMusicSnapshot, + musicLiveActivityEnabled: coordinator.musicLiveActivityEnabled, + closedMusicContentEnabled: Defaults[.enableMinimalisticUI] || Defaults[.showStandardMediaControls], + hideOnClosed: vm.hideOnClosed, + isLocked: LockScreenManager.shared.isLocked, + isDeferredAfterUnlock: LockScreenManager.shared.shouldDelayPostUnlockMusicHUD + ) + } + /// Adjusts a base notch size for a specific screen by adding Dynamic Island /// shadow insets and top-offset only when the screen lacks a physical notch /// and the user has chosen the Dynamic Island style. @@ -772,8 +820,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } }.store(in: &cancellables) - // Pin/unpin the notch above all spaces when the hide option changes: - // "Never hide" joins the max-level CGSSpace, the hide options leave it. + // The hide option changes fullscreen visibility, not Spaces pinning. + // Re-sync in case the user changes it while macOS is moving Spaces. Defaults.publisher(.hideNotchOption, options: []).sink { [weak self] _ in Task { @MainActor [weak self] in self?.syncNotchSpaceMembership() @@ -814,6 +862,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil ) + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.reassertDynamicIslandWindowSpacePresence() + self?.adjustWindowPosition() + } + } + NotificationCenter.default.addObserver( forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil ) { [weak self] _ in @@ -1418,15 +1477,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { if window == nil { window = createDynamicIslandWindow(for: selectedScreen, with: vm) } - if let window = window { positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) - + if vm.notchState == .closed { vm.close() } } } + + syncNotchSpaceMembership() } @objc func togglePopover(_ sender: Any?) { diff --git a/DynamicIsland/components/Live activities/PulsingModifier.swift b/DynamicIsland/components/Live activities/PulsingModifier.swift new file mode 100644 index 00000000..af26058e --- /dev/null +++ b/DynamicIsland/components/Live activities/PulsingModifier.swift @@ -0,0 +1,34 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +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/Notch/DynamicIslandWindow.swift b/DynamicIsland/components/Notch/DynamicIslandWindow.swift index 54efbca9..fc661de4 100644 --- a/DynamicIsland/components/Notch/DynamicIslandWindow.swift +++ b/DynamicIsland/components/Notch/DynamicIslandWindow.swift @@ -23,6 +23,13 @@ import Cocoa class DynamicIslandWindow: NSPanel { + static let pinnedCollectionBehavior: NSWindow.CollectionBehavior = [ + .fullScreenAuxiliary, + .canJoinAllSpaces, + .ignoresCycle, + .stationary, + ] + override init( contentRect: NSRect, styleMask: NSWindow.StyleMask, @@ -44,12 +51,7 @@ class DynamicIslandWindow: NSPanel { backgroundColor = .clear isMovable = false - collectionBehavior = [ - .fullScreenAuxiliary, - .canJoinAllSpaces, - .ignoresCycle, - .stationary, - ] + collectionBehavior = Self.pinnedCollectionBehavior isReleasedWhenClosed = false level = .mainMenu + 3 diff --git a/DynamicIsland/components/Notch/NotchLLMUsageView.swift b/DynamicIsland/components/Notch/NotchLLMUsageView.swift index 43f3a19c..4f2bb52a 100644 --- a/DynamicIsland/components/Notch/NotchLLMUsageView.swift +++ b/DynamicIsland/components/Notch/NotchLLMUsageView.swift @@ -24,13 +24,18 @@ struct NotchLLMUsageView: View { private func isEnabled(_ provider: ProviderID) -> Bool { Defaults[provider.enabledKey] } + private var enabledProviders: [ProviderID] { + ProviderID.allCases.filter { isEnabled($0) } + } + var body: some View { HStack(alignment: .top, spacing: 10) { - ForEach(ProviderID.allCases.filter { isEnabled($0) }) { provider in + ForEach(enabledProviders) { provider in card(for: provider) } } .padding(.horizontal, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .environment(\.colorScheme, .dark) .onAppear { manager.refreshAll() } } @@ -50,6 +55,7 @@ struct NotchLLMUsageView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) + .frame(height: llmUsageProviderCardHeight, alignment: .topLeading) .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) } diff --git a/DynamicIsland/components/Recording/RecordingLiveActivity.swift b/DynamicIsland/components/Recording/RecordingLiveActivity.swift index cc48ebda..241416cf 100644 --- a/DynamicIsland/components/Recording/RecordingLiveActivity.swift +++ b/DynamicIsland/components/Recording/RecordingLiveActivity.swift @@ -22,75 +22,216 @@ 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 - + @Default(.recordingHoverStyle) private var recordingHoverStyle + @Default(.recordingControlMode) private var recordingControlMode + @Default(.enableMinimalisticUI) private var enableMinimalisticUI + + @Binding var hoverAnimation: Bool + @Binding var gestureProgress: CGFloat + @State private var isVisible = 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)) + VStack(spacing: 0) { + Spacer(minLength: 0) + + HStack(spacing: 0) { + recordingBadge + .frame(width: leadingWidth, height: rowHeight) + + Rectangle() + .fill(.black) + .frame(width: centerWidth, height: vm.effectiveClosedNotchHeight) + + trailingStatus + .frame(width: trailingWidth, height: rowHeight) + } + .frame(width: hudWidth, height: vm.effectiveClosedNotchHeight) + + if presentation == .expanded { + expandedDetails + .frame(width: hudWidth, height: expandedDetailsHeight) + .transition(.opacity) + } } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .frame(width: hudWidth, height: hudHeight, alignment: .bottom) + .accessibilityElement(children: .contain) .onAppear { - withAnimation(.smooth(duration: 0.4)) { - isExpanded = true + withAnimation(.smooth(duration: 0.32)) { + isVisible = true } } - .onChange(of: recordingManager.isRecording) { _, newValue in - if !newValue { - withAnimation(.smooth(duration: 0.4)) { - isExpanded = false - } - } else { - withAnimation(.smooth(duration: 0.4)) { - isExpanded = true - } + .onChange(of: recordingManager.isRecording) { _, isRecording in + withAnimation(.smooth(duration: isRecording ? 0.32 : 0.2)) { + isVisible = isRecording } } } -} -// Pulsing animation modifier for recording indicator -// Note: Also used by PrivacyLiveActivity -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 + private var presentation: RecordingHUDPresentation { + guard hoverAnimation, stopControlsEnabled else { return .compact } + return effectiveRecordingHoverStyle == .default ? .expanded : .inline + } + + private var stopControlsEnabled: Bool { + recordingControlMode == .withStopButton && recordingManager.canStopFromHUD + } + + private var effectiveRecordingHoverStyle: RecordingHoverStyle { + enableMinimalisticUI ? recordingHoverStyle : .inline + } + + private var hudWidth: CGFloat { + vm.closedNotchSize.width + presentation.extraWidth + } + + private var hudHeight: CGFloat { + vm.effectiveClosedNotchHeight + presentation.extraHeight + } + + private var centerWidth: CGFloat { + vm.closedNotchSize.width + (hoverAnimation ? 8 : 0) + } + + private var rowHeight: CGFloat { + max(0, vm.effectiveClosedNotchHeight - 12) + } + + private var leadingWidth: CGFloat { + guard isVisible else { return 0 } + return max(0, vm.effectiveClosedNotchHeight - 12 + gestureProgress / 2) + } + + private var trailingWidth: CGFloat { + guard isVisible else { return 0 } + return max(0, hudWidth - centerWidth - leadingWidth) + } + + private var inlineStopButtonSize: CGFloat { + min(max(vm.effectiveClosedNotchHeight - 8, 22), 30) + } + + private var expandedDetailsHeight: CGFloat { + RecordingHUDPresentation.expanded.extraHeight + } + + private var recordingStatusText: String { + recordingManager.stopFailureMessage ?? String(localized: "Recording in progress.") + } + + @ViewBuilder + private var recordingBadge: some View { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.15)) + + recordingDot(size: 10) + } + .frame(width: rowHeight, height: rowHeight) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .opacity(isVisible ? 1 : 0) + } + + @ViewBuilder + private var trailingStatus: some View { + HStack(spacing: 10) { + Text(recordingManager.stopFailureMessage == nil ? recordingManager.formattedDuration : String(localized: "Failed")) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.red) + .lineLimit(1) + .minimumScaleFactor(0.72) + .contentTransition(.numericText()) + + if presentation == .inline { + stopButton(size: inlineStopButtonSize, lineWidth: 1.6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .padding(.trailing, 10) + .opacity(isVisible ? 1 : 0) + } + + private var expandedDetails: some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(verbatim: "Screen Recording") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.84)) + .lineLimit(1) + + Text(recordingManager.formattedDuration) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.red) + .lineLimit(1) + .contentTransition(.numericText()) } + + Text(recordingStatusText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(recordingManager.stopFailureMessage == nil ? .gray.opacity(0.6) : .red.opacity(0.78)) + .lineLimit(1) + } + + Spacer(minLength: 8) + + stopButton(size: 54, lineWidth: 2.4) + } + .padding(.leading, 35) + .padding(.trailing, 40) + .padding(.bottom, 20) + } + + private func recordingDot(size: CGFloat) -> some View { + Circle() + .fill(Color.red) + .frame(width: size, height: size) + .modifier(PulsingModifier()) + } + + private func stopButton(size: CGFloat, lineWidth: CGFloat) -> some View { + Button { + recordingManager.stopActiveRecording() + } label: { + ZStack { + Circle() + .strokeBorder(Color.white.opacity(recordingManager.isSendingStopRequest ? 0.5 : 0.95), lineWidth: lineWidth) + + RoundedRectangle(cornerRadius: max(3, size * 0.1), style: .continuous) + .fill(Color.red) + .frame(width: size * 0.38, height: size * 0.38) + .scaleEffect(recordingManager.isSendingStopRequest ? 0.84 : 1.0) } + .frame(width: size, height: size) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(!recordingManager.canStopFromHUD) + .help(String(localized: "Stop recording")) + .accessibilityLabel(String(localized: "Stop recording")) + } +} + +private enum RecordingHUDPresentation { + case compact + case inline + case expanded + + var extraWidth: CGFloat { + switch self { + case .compact: + return 132 + case .inline: + return 176 + case .expanded: + return 140 + } + } + + var extraHeight: CGFloat { + self == .expanded ? 70 : 0 } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 1bf7cfef..6ef66cf6 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -717,6 +717,8 @@ struct SettingsView: View { // Live Activities SettingsSearchEntry(tab: .liveActivities, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Screen Recording Detection")), SettingsSearchEntry(tab: .liveActivities, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Recording Indicator")), + SettingsSearchEntry(tab: .liveActivities, title: "Recording Controls", keywords: ["screen recording", "stop button", "indicator"], highlightID: SettingsTab.liveActivities.highlightID(for: "Recording Controls")), + SettingsSearchEntry(tab: .liveActivities, title: "Recording Hover Style", keywords: ["screen recording", "hover", "inline", "stop"], highlightID: SettingsTab.liveActivities.highlightID(for: "Recording Hover Style")), SettingsSearchEntry(tab: .liveActivities, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Focus Detection")), SettingsSearchEntry(tab: .liveActivities, title: "Show Focus Indicator", keywords: ["focus icon", "moon"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Focus Indicator")), SettingsSearchEntry(tab: .liveActivities, title: "Show Focus Label", keywords: ["focus label", "text"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Focus Label")), @@ -4037,6 +4039,10 @@ struct LiveActivitiesSettings: View { @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection + @Default(.showRecordingIndicator) var showRecordingIndicator + @Default(.recordingHoverStyle) var recordingHoverStyle + @Default(.recordingControlMode) var recordingControlMode + @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent @Default(.capsLockIndicatorTintMode) var capsLockTintMode @@ -4045,6 +4051,10 @@ struct LiveActivitiesSettings: View { SettingsTab.liveActivities.highlightID(for: title) } + private var availableRecordingHoverStyles: [RecordingHoverStyle] { + enableMinimalisticUI ? RecordingHoverStyle.allCases : [.inline] + } + var body: some View { Form { Section { @@ -4059,6 +4069,40 @@ struct LiveActivitiesSettings: View { .disabled(!enableScreenRecordingDetection) .settingsHighlight(id: highlightID("Show Recording Indicator")) + VStack(alignment: .leading, spacing: 8) { + Picker("Recording controls", selection: $recordingControlMode) { + ForEach(RecordingControlMode.allCases) { mode in + Text(mode.title) + .tag(mode) + } + } + .pickerStyle(.segmented) + + Text("Indicator only keeps the recording live activity passive. With stop button enables native recording controls.") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(!enableScreenRecordingDetection) + .settingsHighlight(id: highlightID("Recording Controls")) + + VStack(alignment: .leading, spacing: 8) { + Picker("Recording hover style", selection: $recordingHoverStyle) { + ForEach(availableRecordingHoverStyles) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + + Text(enableMinimalisticUI + ? "Default uses the expanded recording HUD. Inline keeps the stop control inside the notch height." + : "Default hover style is available only in Minimalistic mode. Inline will be used here.") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(!enableScreenRecordingDetection || !showRecordingIndicator || recordingControlMode != .withStopButton) + .settingsHighlight(id: highlightID("Recording Hover Style")) + if recordingManager.isMonitoring { HStack { Text("Detection Status") @@ -4256,6 +4300,16 @@ struct LiveActivitiesSettings: View { .navigationTitle("Live Activities") .onAppear { fullDiskAccessPermission.refreshStatus() + normalizeRecordingHoverStyle() + } + .onChange(of: enableMinimalisticUI) { _, _ in + normalizeRecordingHoverStyle() + } + } + + private func normalizeRecordingHoverStyle() { + if !enableMinimalisticUI && recordingHoverStyle == .default { + recordingHoverStyle = .inline } } } diff --git a/DynamicIsland/helpers/ClosedMusicPairingVisibility.swift b/DynamicIsland/helpers/ClosedMusicPairingVisibility.swift new file mode 100644 index 00000000..78107e98 --- /dev/null +++ b/DynamicIsland/helpers/ClosedMusicPairingVisibility.swift @@ -0,0 +1,35 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +func isClosedMusicPairingEligible( + notchState: NotchState, + hasActiveMusicSnapshot: Bool, + musicLiveActivityEnabled: Bool, + closedMusicContentEnabled: Bool, + hideOnClosed: Bool, + isLocked: Bool, + isDeferredAfterUnlock: Bool +) -> Bool { + notchState == .closed + && hasActiveMusicSnapshot + && musicLiveActivityEnabled + && closedMusicContentEnabled + && !hideOnClosed + && !isLocked + && !isDeferredAfterUnlock +} diff --git a/DynamicIsland/helpers/RecordingHUDLayout.swift b/DynamicIsland/helpers/RecordingHUDLayout.swift new file mode 100644 index 00000000..96968ffe --- /dev/null +++ b/DynamicIsland/helpers/RecordingHUDLayout.swift @@ -0,0 +1,90 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import CoreGraphics + +struct RecordingHUDLayout { + let isVisible: Bool + let stopControlsEnabled: Bool + let hoverStyle: RecordingHoverStyle + let expanded: Bool + + var showsDefaultExpansion: Bool { + isVisible && stopControlsEnabled && expanded && hoverStyle == .default + } + + var showsInlineExpansion: Bool { + isVisible && stopControlsEnabled && expanded && hoverStyle == .inline + } + + var extraWidth: CGFloat { + guard isVisible else { return 0 } + guard stopControlsEnabled && expanded else { return 132 } + + switch hoverStyle { + case .default: + return 140 + case .inline: + return 176 + } + } + + var extraHeight: CGFloat { + showsDefaultExpansion ? 70 : 0 + } + + func size(closedNotchSize: CGSize, effectiveClosedNotchHeight: CGFloat) -> CGSize? { + guard isVisible else { return nil } + + return CGSize( + width: closedNotchSize.width + extraWidth, + height: effectiveClosedNotchHeight + extraHeight + ) + } +} + +func makeRecordingHUDLayout( + notchState: NotchState, + screenRecordingDetectionEnabled: Bool, + showRecordingIndicator: Bool, + hideOnClosed: Bool, + isRecording: Bool, + closedMusicPairingEligible: Bool, + recordingControlMode: RecordingControlMode, + canStopFromHUD: Bool, + enableMinimalisticUI: Bool, + recordingHoverStyle: RecordingHoverStyle, + expanded: Bool +) -> RecordingHUDLayout { + let isVisible = notchState == .closed + && screenRecordingDetectionEnabled + && showRecordingIndicator + && !hideOnClosed + && isRecording + && !closedMusicPairingEligible + + let stopControlsEnabled = recordingControlMode == .withStopButton + && canStopFromHUD + + return RecordingHUDLayout( + isVisible: isVisible, + stopControlsEnabled: stopControlsEnabled, + hoverStyle: enableMinimalisticUI ? recordingHoverStyle : .inline, + expanded: expanded + ) +} diff --git a/DynamicIsland/managers/ScreenRecordingManager.swift b/DynamicIsland/managers/ScreenRecordingManager.swift index 7411cb52..fee860a9 100644 --- a/DynamicIsland/managers/ScreenRecordingManager.swift +++ b/DynamicIsland/managers/ScreenRecordingManager.swift @@ -17,18 +17,15 @@ */ import Foundation -import Combine import AppKit -import Defaults import SwiftUI -// MARK: - Private API Declarations -// These private APIs provide direct screen capture detection -// Use at your own risk - may break in future macOS versions - @_silgen_name("CGSIsScreenWatcherPresent") func CGSIsScreenWatcherPresent() -> Bool +// Private SkyLight/CGS symbols are intentionally isolated here. They provide +// event-driven screen recording detection without polling, but they are not +// public API and may change across macOS releases. @_silgen_name("CGSRegisterNotifyProc") func CGSRegisterNotifyProc( _ callback: (@convention(c) (Int32, Int32, Int32, UnsafeMutableRawPointer?) -> Void)?, @@ -36,102 +33,152 @@ func CGSRegisterNotifyProc( _ context: UnsafeMutableRawPointer? ) -> Bool -// MARK: - Global Callback Function -// C function pointer cannot capture context, so we need a global function +private func screenRecordingDebugLog(_ message: String) { +#if DEBUG + print("ScreenRecordingManager: \(message)") +#endif +} + private func screenCaptureEventCallback(eventType: Int32, _: Int32, _: Int32, context: UnsafeMutableRawPointer?) { - guard let context = context else { return } + guard let context else { return } let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() - + DispatchQueue.main.async { - print("ScreenRecordingManager: đŸ“ĸ Screen capture event received (type: \(eventType))") + screenRecordingDebugLog("Screen capture event received (type: \(eventType))") manager.checkRecordingStatus() } } +enum RecordingStopControlState: Equatable { + case unavailable + case ready + case sending + case failed(String) + + var canSubmitStopRequest: Bool { + switch self { + case .ready, .failed: + return true + case .unavailable, .sending: + return false + } + } + + var isSending: Bool { + self == .sending + } + + var failureMessage: String? { + guard case let .failed(message) = self else { return nil } + return message + } +} + +protocol ScreenRecordingStopControlling { + func requestStop() async +} + +struct NativeScreenRecordingStopController: ScreenRecordingStopControlling { + func requestStop() async { + await sendStopRecordingShortcutViaSystemEvents() + + if Task.isCancelled { return } + sendStopRecordingShortcut() + } + + private func sendStopRecordingShortcutViaSystemEvents() async { + let script = """ + tell application "System Events" + key code 53 using {command down, control down} + end tell + """ + + do { + try await AppleScriptHelper.executeVoid(script) + screenRecordingDebugLog("Sent Command-Control-Escape through System Events") + } catch { + screenRecordingDebugLog("System Events stop shortcut failed: \(error)") + } + } + + private func sendStopRecordingShortcut() { + guard let source = CGEventSource(stateID: .hidSystemState) else { + screenRecordingDebugLog("Unable to create CGEventSource for stop shortcut") + return + } + + let flags: CGEventFlags = [.maskCommand, .maskControl] + let keyCode = CGKeyCode(53) + let taps: [CGEventTapLocation] = [.cghidEventTap, .cgSessionEventTap] + + for tap in taps { + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true) + keyDown?.flags = flags + keyDown?.post(tap: tap) + + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) + keyUp?.flags = flags + keyUp?.post(tap: tap) + } + + screenRecordingDebugLog("Sent Command-Control-Escape CGEvent stop shortcut") + } +} + @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 cancellables = Set() + @Published var stopControlState: RecordingStopControlState = .unavailable + + private let coordinator = DynamicIslandViewCoordinator.shared + private let stopController: ScreenRecordingStopControlling private var recordingStartTime: Date? private var durationTimer: Timer? - private var debounceIdleTask: Task? - - // MARK: - Configuration - private let debounceDelay: TimeInterval = 0.2 // Debounce rapid changes - - // MARK: - Initialization - private init() { - // No initial setup needed + private var stopRequestTask: Task? + private var stopFailureClearTask: Task? + + private init(stopController: ScreenRecordingStopControlling = NativeScreenRecordingStopController()) { + self.stopController = stopController } - + deinit { - // Clean up monitoring state - // Note: We can't call async methods in deinit, so we just clean up local state - debounceIdleTask?.cancel() + stopRequestTask?.cancel() + stopFailureClearTask?.cancel() durationTimer?.invalidate() } - - // MARK: - Public Methods - - /// Start monitoring for screen recording activity + func startMonitoring() { - guard !isMonitoring else { - print("ScreenRecordingManager: Already monitoring, skipping start") - return + guard !isMonitoring else { + screenRecordingDebugLog("Already monitoring, skipping start") + return } - + isMonitoring = true - - print("ScreenRecordingManager: đŸŸĸ Starting screen capture monitoring (Private API)...") - - // Setup event-driven capture detection using private CoreGraphics APIs setupPrivateAPINotifications() - - // Check initial state checkRecordingStatus() - - print("ScreenRecordingManager: ✅ Started monitoring (event-driven, no polling)") + screenRecordingDebugLog("Started screen capture monitoring") } - - /// Stop monitoring for screen recording activity + func stopMonitoring() { - guard isMonitoring else { - print("ScreenRecordingManager: Not monitoring, skipping stop") - return + guard isMonitoring else { + screenRecordingDebugLog("Not monitoring, skipping stop") + return } - - print("ScreenRecordingManager: 🛑 Stopping monitoring...") - + isMonitoring = false - - // Note: We don't unregister the callback as there's no CGSUnregisterNotifyProc API - // The callback will simply not be processed when isMonitoring is false - - // 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") + stopRequestTask?.cancel() + stopRequestTask = nil + clearStopFailure() + stopControlState = .unavailable + screenRecordingDebugLog("Stopped screen capture monitoring") } - - /// Toggle monitoring state + func toggleMonitoring() { if isMonitoring { stopMonitoring() @@ -139,137 +186,157 @@ class ScreenRecordingManager: ObservableObject { startMonitoring() } } - - // MARK: - Private Methods - - /// Setup private API notifications for screen capture events + + func stopActiveRecording() { + guard isRecording, stopControlState.canSubmitStopRequest else { return } + + clearStopFailure() + stopControlState = .sending + stopRequestTask?.cancel() + + stopRequestTask = Task { @MainActor [weak self] in + guard let self else { return } + + await stopController.requestStop() + guard !Task.isCancelled else { return } + + try? await Task.sleep(for: .milliseconds(550)) + guard !Task.isCancelled else { return } + + checkRecordingStatus() + + if isRecording { + publishStopFailure() + } else { + clearStopFailure() + stopControlState = .unavailable + } + + stopRequestTask = nil + } + } + private func setupPrivateAPINotifications() { - // Pass self as context to the global callback function let context = Unmanaged.passUnretained(self).toOpaque() - - // Register for remote session events (screen capture start/stop) - // kCGSessionRemoteConnect - fires when screen sharing/recording starts - let registered1 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1502, context) - - // kCGSessionRemoteDisconnect - fires when screen sharing/recording stops - let registered2 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1503, context) - - if registered1 && registered2 { - print("ScreenRecordingManager: ✅ Private API notifications registered") + let registeredConnect = CGSRegisterNotifyProc(screenCaptureEventCallback, 1502, context) + let registeredDisconnect = CGSRegisterNotifyProc(screenCaptureEventCallback, 1503, context) + + if registeredConnect && registeredDisconnect { + screenRecordingDebugLog("Private API notifications registered") } else { - print("ScreenRecordingManager: âš ī¸ Failed to register private API notifications") + screenRecordingDebugLog("Failed to register private API notifications") } } - - /// Check current recording status using private API + func checkRecordingStatus() { let currentRecordingState = CGSIsScreenWatcherPresent() - - // 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))") - - if currentRecordingState && !isRecording { - // Started recording - lastUpdated = Date() - startDurationTracking() - updateIdleState(recording: true) - // Trigger expanding view like music activity - coordinator.toggleExpandingView(status: true, type: .recording) - withAnimation(.smooth) { - isRecording = currentRecordingState - } - print("ScreenRecordingManager: 🔴 Screen recording STARTED") - } else if !currentRecordingState && isRecording { - // Stopped recording - let expanding view auto-collapse naturally (like music) - lastUpdated = Date() - stopDurationTracking() - updateIdleState(recording: false) - withAnimation(.smooth) { - isRecording = currentRecordingState - } - print("ScreenRecordingManager: âšĒ Screen recording STOPPED") + guard currentRecordingState != isRecording else { return } + + if currentRecordingState { + startDurationTracking() + clearStopFailure() + stopControlState = .ready + coordinator.toggleExpandingView(status: true, type: .recording) + withAnimation(.smooth) { + isRecording = true } + screenRecordingDebugLog("Screen recording started") + } else { + stopDurationTracking() + stopRequestTask?.cancel() + stopRequestTask = nil + stopControlState = .unavailable + clearStopFailure() + coordinator.toggleExpandingView(status: false, type: .recording) + withAnimation(.smooth) { + isRecording = false + } + screenRecordingDebugLog("Screen recording stopped") } } - - /// Start tracking recording duration + private func startDurationTracking() { recordingStartTime = Date() recordingDuration = 0 - - durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + durationTimer?.invalidate() + durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor in self?.updateDuration() } } - - print("ScreenRecordingManager: âąī¸ Started duration tracking") + + screenRecordingDebugLog("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 + guard let self, !self.isRecording, self.recordingStartTime == nil else { return } + self.recordingDuration = 0 } - - print("ScreenRecordingManager: âšī¸ Stopped duration tracking") + + screenRecordingDebugLog("Stopped duration tracking") } - - /// Update the current recording duration + private func updateDuration() { - guard let startTime = recordingStartTime else { return } - recordingDuration = Date().timeIntervalSince(startTime) + guard let recordingStartTime else { return } + recordingDuration = Date().timeIntervalSince(recordingStartTime) } - - /// 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 - } - } - } + + private func clearStopFailure() { + stopFailureClearTask?.cancel() + stopFailureClearTask = nil + + if case .failed = stopControlState { + stopControlState = isRecording ? .ready : .unavailable + } + } + + private func publishStopFailure() { + let message = String(localized: "Unable to stop recording. Use Command-Control-Escape or the macOS recording menu.") + stopControlState = .failed(message) + NSSound.beep() + + stopFailureClearTask?.cancel() + stopFailureClearTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard let self, !Task.isCancelled else { return } + if case .failed = stopControlState { + stopControlState = isRecording ? .ready : .unavailable } + stopFailureClearTask = nil } } } -// MARK: - Extensions - extension ScreenRecordingManager { - /// Get current recording status without async var currentRecordingStatus: Bool { - return isRecording + isRecording } - - /// Check if monitoring is available (for settings UI) + var isMonitoringAvailable: Bool { - return true // Window-based monitoring is always available + true + } + + var isSendingStopRequest: Bool { + stopControlState.isSending + } + + var canStopFromHUD: Bool { + isRecording && stopControlState.canSubmitStopRequest + } + + var stopFailureMessage: String? { + stopControlState.failureMessage } - - /// 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 f52e9be4..9c08ba33 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -511,6 +511,38 @@ enum BatteryNotificationStyle: String, CaseIterable, Identifiable, Defaults.Seri } } +enum RecordingHoverStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case `default` + case inline + + var id: String { rawValue } + + var title: String { + switch self { + case .default: + return String(localized: "Default") + case .inline: + return String(localized: "Inline") + } + } +} + +enum RecordingControlMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case indicatorOnly + case withStopButton + + var id: String { rawValue } + + var title: String { + switch self { + case .indicatorOnly: + return String(localized: "Indicator only") + case .withStopButton: + return String(localized: "With stop button") + } + } +} + enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { case shuffle case repeatMode @@ -1258,6 +1290,8 @@ extension Defaults.Keys { // MARK: Screen Recording Detection Feature static let enableScreenRecordingDetection = Key("enableScreenRecordingDetection", default: true) static let showRecordingIndicator = Key("showRecordingIndicator", default: true) + static let recordingHoverStyle = Key("recordingHoverStyle", default: .default) + static let recordingControlMode = Key("recordingControlMode", default: .withStopButton) // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index df0d9006..9f5cbe58 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -367,6 +367,11 @@ class DynamicIslandViewModel: NSObject, ObservableObject { return adjustedSize } + if coordinator.currentView == .llmUsage { + adjustedSize.height = max(adjustedSize.height, llmUsageOpenNotchHeight) + return adjustedSize + } + return statsAdjustedNotchSize( from: adjustedSize, isStatsTabActive: coordinator.currentView == .stats, diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 559bba66..9f41b3ae 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -128,6 +128,8 @@ let minimalisticTimerCountdownContentHeight: CGFloat = 82 let minimalisticTimerCountdownBlockHeight: CGFloat = minimalisticTimerCountdownTopPadding + minimalisticTimerCountdownContentHeight let statsSecondRowContentHeight: CGFloat = 120 let statsGridSpacingHeight: CGFloat = 12 +let llmUsageOpenNotchHeight: CGFloat = 220 +let llmUsageProviderCardHeight: CGFloat = 164 let notchShadowPaddingStandard: CGFloat = 18 let notchShadowPaddingMinimalistic: CGFloat = 12