diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index d554847..e070a19 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -126,6 +126,20 @@ struct ContentView: View { } return cornerRadiusInsets } + + private var currentShadowPadding: CGFloat { + notchShadowPaddingValue(isMinimalistic: enableMinimalisticUI) + } + + private var currentNotchShape: NotchShape { + let topRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling]) + ? activeCornerRadiusInsets.opened.top + : activeCornerRadiusInsets.closed.top + let bottomRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling]) + ? activeCornerRadiusInsets.opened.bottom + : activeCornerRadiusInsets.closed.bottom + return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius) + } var body: some View { let interactionsEnabled = !lockScreenManager.isLocked @@ -139,16 +153,20 @@ struct ContentView: View { ) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) - .mask { - ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) - ? NotchShape(topCornerRadius: activeCornerRadiusInsets.opened.top, bottomCornerRadius: activeCornerRadiusInsets.opened.bottom) - .drawingGroup() - : NotchShape(topCornerRadius: activeCornerRadiusInsets.closed.top, bottomCornerRadius: activeCornerRadiusInsets.closed.bottom) - .drawingGroup() - } - .padding(.bottom, vm.notchState == .open && Defaults[.extendHoverArea] ? 0 : (vm.effectiveClosedNotchHeight == 0) - ? zeroHeightHoverPadding - : 0 + .clipShape(currentNotchShape) + .compositingGroup() + .shadow( + color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) + ? .black.opacity(0.6) + : .clear, + radius: Defaults[.cornerRadiusScaling] ? 10 : 5 + ) + .padding(.bottom, + currentShadowPadding + ( + vm.notchState == .open && Defaults[.extendHoverArea] + ? 0 + : (vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0) + ) ) mainLayout @@ -280,7 +298,11 @@ struct ContentView: View { // .keyboardShortcut("E", modifiers: .command) } } - .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) + .frame( + maxWidth: dynamicNotchSize.width, + maxHeight: dynamicNotchSize.height + currentShadowPadding, + alignment: .top + ) .animation(dynamicNotchResizeAnimation, value: dynamicNotchSize) .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .environmentObject(privacyManager) @@ -291,7 +313,6 @@ struct ContentView: View { vm.shouldRecheckHover.toggle() } } - .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5) .background(dragDetector) .environmentObject(vm) .environmentObject(webcamManager) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 04417c5..80944c4 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -276,33 +276,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } - // Use minimalistic or normal size based on settings - var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - // Only apply dynamic sizing when on stats tab and stats are enabled - guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else { - return baseSize - } - - let enabledGraphsCount = [ - Defaults[.showCpuGraph], - Defaults[.showMemoryGraph], - Defaults[.showGpuGraph], - Defaults[.showNetworkGraph], - Defaults[.showDiskGraph] - ].filter { $0 }.count - - // Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows - var requiredHeight = baseSize.height - - if enabledGraphsCount >= 4 { - // Two rows needed - add height for second row plus spacing - let extraHeight: CGFloat = 120 + 12 // Graph height + spacing - requiredHeight = baseSize.height + extraHeight - } - - // Width stays constant - no horizontal expansion - return CGSize(width: baseSize.width, height: requiredHeight) + // Use minimalistic or normal size based on settings, then adjust for stats layout + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + let adjustedContentSize = statsAdjustedNotchSize( + from: baseSize, + isStatsTabActive: coordinator.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) + return addShadowPadding( + to: adjustedContentSize, + isMinimalistic: Defaults[.enableMinimalisticUI] + ) } func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) { @@ -781,6 +765,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("OnboardingWindow") + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) + onboardingWindowController = NSWindowController(window: window) } diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index d2ed741..68c1c9f 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -48,7 +48,16 @@ struct ExpandedItem { class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() - @Published var currentView: NotchViews = .home + @Published var currentView: NotchViews = .home { + didSet { + handleStatsTabTransition(from: oldValue, to: currentView) + } + } + + @Published var statsSecondRowExpansion: CGFloat = 1 + private var statsSecondRowWorkItem: DispatchWorkItem? + private let statsSecondRowRevealDelay: TimeInterval = 0.5 + private let statsSecondRowAnimationDuration: TimeInterval = 0.3 @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -91,6 +100,27 @@ class DynamicIslandViewCoordinator: ObservableObject { private init() { selectedScreen = preferredScreen } + + private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { + guard oldValue != newValue else { return } + statsSecondRowWorkItem?.cancel() + if newValue == .stats && Defaults[.enableStatsFeature] { + statsSecondRowExpansion = 0 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + withAnimation(.easeInOut(duration: self.statsSecondRowAnimationDuration)) { + self.statsSecondRowExpansion = 1 + } + self.statsSecondRowWorkItem = nil + } + statsSecondRowWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + statsSecondRowRevealDelay, execute: workItem) + } else { + withAnimation(.easeInOut(duration: 0.2)) { + statsSecondRowExpansion = 0 + } + } + } func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { let resolvedDuration: TimeInterval diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift index dec51fb..3952fb7 100644 --- a/DynamicIsland/components/Clipboard/ClipboardPanel.swift +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -7,7 +7,16 @@ import AppKit import SwiftUI -import Defaults + +private func applyClipboardCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} class ClipboardPanel: NSPanel { @@ -51,10 +60,8 @@ class ClipboardPanel: NSPanel { .stationary, .fullScreenAuxiliary // Float above full-screen apps ] - - // Apply screenshot protection - updateScreenshotProtection() - setupScreenshotProtectionObserver() + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) // Accept mouse moved events for proper hover behavior acceptsMouseMovedEvents = true @@ -66,6 +73,7 @@ class ClipboardPanel: NSPanel { } let hostingView = NSHostingView(rootView: contentView) + applyClipboardCornerMask(hostingView, radius: 12) self.contentView = hostingView // Set initial size @@ -140,28 +148,6 @@ class ClipboardPanel: NSPanel { setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) } - private func setupScreenshotProtectionObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenshotProtection() - } - } - } - - private func updateScreenshotProtection() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Exclude from screenshots and screen recordings - self.sharingType = .none - print("🙈 ClipboardPanel: Protected from screenshots and screen recordings") - } else { - // Allow normal screenshot inclusion - self.sharingType = .readOnly - print("đŸ‘ī¸ ClipboardPanel: Visible in screenshots and screen recordings") - } - } } struct ClipboardPanelView: View { diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift index ad39a0c..305c2a5 100644 --- a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -9,6 +9,16 @@ import SwiftUI import Cocoa import Defaults +private func applyColorPickerCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + class ColorPickerPanelManager: ObservableObject { static let shared = ColorPickerPanelManager() @@ -93,9 +103,7 @@ class ColorPickerPanel: NSPanel { .fullScreenAuxiliary // Float above full-screen apps ] - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) // Accept mouse moved events for proper hover behavior acceptsMouseMovedEvents = true @@ -105,6 +113,7 @@ class ColorPickerPanel: NSPanel { } let hostingView = NSHostingView(rootView: panelView) + applyColorPickerCornerMask(hostingView, radius: 12) contentView = hostingView // Set initial size @@ -161,27 +170,8 @@ class ColorPickerPanel: NSPanel { saveCurrentPosition() } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - self.sharingType = .none - print("🙈 ColorPickerPanel: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - self.sharingType = .readOnly - print("đŸ‘ī¸ ColorPickerPanel: Visible in screen capture and recordings") - } + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) } } diff --git a/DynamicIsland/components/Notch/DynamicIslandWindow.swift b/DynamicIsland/components/Notch/DynamicIslandWindow.swift index 47e9bc9..5118ae7 100644 --- a/DynamicIsland/components/Notch/DynamicIslandWindow.swift +++ b/DynamicIsland/components/Notch/DynamicIslandWindow.swift @@ -38,6 +38,8 @@ class DynamicIslandWindow: NSPanel { isReleasedWhenClosed = false level = .mainMenu + 3 hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(self, scope: .entireInterface) } override var canBecomeKey: Bool { diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 9bb3dd7..02ee976 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -67,6 +67,7 @@ struct NotchStatsView: View { @State private var statsHoverGraceActive = false @State private var statsHoverGraceWorkItem: DispatchWorkItem? @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared var availableGraphs: [GraphData] { var graphs: [GraphData] = [] @@ -148,18 +149,35 @@ struct NotchStatsView: View { } } } else if graphCount == 4 { - // 4 graphs: 2x2 grid - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), - spacing: 12 - ) { - ForEach(0..= 4 && newValue < 0.999 + if shouldHold { + isResizingForStats = true + } else { + DispatchQueue.main.async { + isResizingForStats = false + } + } + } } + +private struct StatsCollapsibleRow: View { + let expansion: CGFloat + let height: CGFloat + let content: () -> Content + private let minVisibleHeight: CGFloat = 0.5 + + init(expansion: CGFloat, height: CGFloat, @ViewBuilder content: @escaping () -> Content) { + self.expansion = expansion + self.height = height + self.content = content + } + + var body: some View { + let clamped = max(0, min(expansion, 1)) + let targetHeight = clamped == 0 ? 0 : max(height * clamped, minVisibleHeight) + + return Group { + if targetHeight == 0 { + Color.clear.frame(height: 0) + } else { + content() + .frame(height: height, alignment: .top) + .clipped() + .mask( + Rectangle() + .frame(height: targetHeight) + .frame(maxWidth: .infinity, alignment: .top) + ) + .frame(height: targetHeight, alignment: .top) + } + } + .animation(.easeInOut(duration: 0.3), value: clamped) + } +} private func activateStatsHoverGrace(duration: TimeInterval = 0.45) { statsHoverGraceWorkItem?.cancel() statsHoverGraceActive = true diff --git a/DynamicIsland/components/ScreenAssistant/ChatPanels.swift b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift index 3932db5..f8115cb 100644 --- a/DynamicIsland/components/ScreenAssistant/ChatPanels.swift +++ b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift @@ -8,6 +8,16 @@ import AppKit import SwiftUI import Defaults +private func applyChatPanelCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + // MARK: - Chat Messages Panel (Left Side) class ChatMessagesPanel: NSPanel { @@ -47,9 +57,7 @@ class ChatMessagesPanel: NSPanel { .fullScreenAuxiliary ] - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) acceptsMouseMovedEvents = true } @@ -57,6 +65,7 @@ class ChatMessagesPanel: NSPanel { private func setupContentView() { let contentView = ChatMessagesView() let hostingView = NSHostingView(rootView: contentView) + applyChatPanelCornerMask(hostingView, radius: 16) self.contentView = hostingView // Set size for chat messages panel (wider and taller) @@ -78,27 +87,8 @@ class ChatMessagesPanel: NSPanel { setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - self.sharingType = .none - print("🙈 ChatMessagesPanel: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - self.sharingType = .readOnly - print("đŸ‘ī¸ ChatMessagesPanel: Visible in screen capture and recordings") - } + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) } } @@ -152,9 +142,7 @@ class ChatInputPanel: NSPanel { .fullScreenAuxiliary ] - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) acceptsMouseMovedEvents = true } @@ -162,6 +150,7 @@ class ChatInputPanel: NSPanel { private func setupContentView() { let contentView = ChatInputView() let hostingView = NSHostingView(rootView: contentView) + applyChatPanelCornerMask(hostingView, radius: 16) self.contentView = hostingView // Set compact size for single-line input panel @@ -183,27 +172,8 @@ class ChatInputPanel: NSPanel { setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - self.sharingType = .none - print("🙈 ChatInputPanel: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - self.sharingType = .readOnly - print("đŸ‘ī¸ ChatInputPanel: Visible in screen capture and recordings") - } + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) } } @@ -786,45 +756,23 @@ struct ScreenshotPopoverBackground: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let view = NSView() - // Apply screen capture protection like our other panels DispatchQueue.main.async { - if let window = view.window { - self.updateScreenCaptureVisibility(window: window) - self.setupScreenCaptureObserver(window: window) - } + guard let window = view.window else { return } + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) } return view } func updateNSView(_ nsView: NSView, context: Context) { - // Update screen capture protection when view updates if let window = nsView.window { - updateScreenCaptureVisibility(window: window) + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) } } - private func updateScreenCaptureVisibility(window: NSWindow) { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - window.sharingType = .none - print("🙈 ScreenshotPopover: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - window.sharingType = .readOnly - print("đŸ‘ī¸ ScreenshotPopover: Visible in screen capture and recordings") - } - } - - private func setupScreenCaptureObserver(window: NSWindow) { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak window] change in - DispatchQueue.main.async { - guard let window = window else { return } - self.updateScreenCaptureVisibility(window: window) - } + static func dismantleNSView(_ nsView: NSView, coordinator: ()) { + if let window = nsView.window { + ScreenCaptureVisibilityManager.shared.unregister(window) } } } \ No newline at end of file diff --git a/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift b/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift index 65e82ff..19f07df 100644 --- a/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift +++ b/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift @@ -8,6 +8,16 @@ import AppKit import SwiftUI import Defaults +private func applyModelSelectionCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + // MARK: - Model Selection Panel class ModelSelectionPanel: NSPanel { @@ -58,9 +68,7 @@ class ModelSelectionPanel: NSPanel { .fullScreenAuxiliary ] - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) acceptsMouseMovedEvents = true } @@ -68,6 +76,7 @@ class ModelSelectionPanel: NSPanel { private func setupContentView() { let contentView = ModelSelectionView() let hostingView = NSHostingView(rootView: contentView) + applyModelSelectionCornerMask(hostingView, radius: 16) self.contentView = hostingView // Set size for model selection panel @@ -89,27 +98,8 @@ class ModelSelectionPanel: NSPanel { setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - self.sharingType = .none - print("🙈 ModelSelectionPanel: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - self.sharingType = .readOnly - print("đŸ‘ī¸ ModelSelectionPanel: Visible in screen capture and recordings") - } + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) } } diff --git a/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift b/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift index 5c36558..064b153 100644 --- a/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift +++ b/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift @@ -57,9 +57,7 @@ class ScreenAssistantPanel: NSPanel { .fullScreenAuxiliary ] - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) // Set minimal size since this is just a wrapper setContentSize(CGSize(width: 1, height: 1)) @@ -77,27 +75,8 @@ class ScreenAssistantPanel: NSPanel { super.close() } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - self.sharingType = .none - print("🙈 ScreenAssistantPanel: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - self.sharingType = .readOnly - print("đŸ‘ī¸ ScreenAssistantPanel: Visible in screen capture and recordings") - } + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 805b8f0..c51894e 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -232,7 +232,7 @@ struct GeneralSettings: View { NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil) } .disabled(showOnAllDisplays) - Defaults.Toggle("Hide panels from screenshots & screen recordings", key: .hidePanelsFromScreenCapture) + Defaults.Toggle("Hide Dynamic Island during screenshots & recordings", key: .hideDynamicIslandFromScreenCapture) } header: { Text("System features") } @@ -356,6 +356,7 @@ struct GeneralSettings: View { Section { Defaults.Toggle("Enable Lock Screen Live Activity", key: .enableLockScreenLiveActivity) + Defaults.Toggle("Play lock/unlock sounds", key: .enableLockSounds) if #available(macOS 26.0, *) { Picker("Lock screen material", selection: $lockScreenGlassStyle) { ForEach(LockScreenGlassStyle.allCases) { style in diff --git a/DynamicIsland/components/Settings/SettingsWindowController.swift b/DynamicIsland/components/Settings/SettingsWindowController.swift index 4d17eec..2a40ddc 100644 --- a/DynamicIsland/components/Settings/SettingsWindowController.swift +++ b/DynamicIsland/components/Settings/SettingsWindowController.swift @@ -7,7 +7,6 @@ import AppKit import SwiftUI -import Defaults import Sparkle class SettingsWindowController: NSWindowController { @@ -65,9 +64,7 @@ class SettingsWindowController: NSWindowController { // Handle window closing window.delegate = self - // Apply screen capture hiding setting - updateScreenCaptureVisibility() - setupScreenCaptureObserver() + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) } func showWindow() { @@ -108,26 +105,9 @@ class SettingsWindowController: NSWindowController { NSApp.setActivationPolicy(.accessory) } - private func setupScreenCaptureObserver() { - // Observe changes to hidePanelsFromScreenCapture setting - Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in - DispatchQueue.main.async { - self?.updateScreenCaptureVisibility() - } - } - } - - private func updateScreenCaptureVisibility() { - let shouldHide = Defaults[.hidePanelsFromScreenCapture] - - if shouldHide { - // Hide from screen capture and recording - window?.sharingType = .none - print("🙈 SettingsWindow: Hidden from screen capture and recordings") - } else { - // Allow normal screen capture - window?.sharingType = .readOnly - print("đŸ‘ī¸ SettingsWindow: Visible in screen capture and recordings") + deinit { + if let window = window { + ScreenCaptureVisibilityManager.shared.unregister(window) } } } diff --git a/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift b/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift new file mode 100644 index 0000000..71993ca --- /dev/null +++ b/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift @@ -0,0 +1,51 @@ +import AppKit +import Combine +import Defaults + +enum ScreenCaptureScope: Int { + case panelsOnly + case entireInterface +} + +final class ScreenCaptureVisibilityManager { + static let shared = ScreenCaptureVisibilityManager() + + private let scopedWindows = NSMapTable(keyOptions: .weakMemory, valueOptions: .strongMemory) + private var cancellables = Set() + + private init() { + let interfacePublisher = Defaults.publisher(.hideDynamicIslandFromScreenCapture) + .map { _ in () } + .eraseToAnyPublisher() + + interfacePublisher + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.updateAllWindows() + } + .store(in: &cancellables) + } + + func register(_ window: NSWindow, scope: ScreenCaptureScope) { + scopedWindows.setObject(NSNumber(value: scope.rawValue), forKey: window) + applyVisibility(to: window, scope: scope) + } + + func unregister(_ window: NSWindow) { + scopedWindows.removeObject(forKey: window) + } + + private func updateAllWindows() { + guard let windows = scopedWindows.keyEnumerator().allObjects as? [NSWindow] else { return } + for window in windows { + guard let raw = scopedWindows.object(forKey: window)?.intValue, + let scope = ScreenCaptureScope(rawValue: raw) else { continue } + applyVisibility(to: window, scope: scope) + } + } + + private func applyVisibility(to window: NSWindow, scope _: ScreenCaptureScope) { + let shouldHide = Defaults[.hideDynamicIslandFromScreenCapture] + window.sharingType = shouldHide ? .none : .readOnly + } +} diff --git a/DynamicIsland/lock.mp3 b/DynamicIsland/lock.mp3 new file mode 100644 index 0000000..5d873fe Binary files /dev/null and b/DynamicIsland/lock.mp3 differ diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index ba03a9d..ae2fa61 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -40,6 +40,10 @@ class BluetoothAudioManager: ObservableObject { private var missingBatteryLog: Set = [] private var lastBatteryStatusUpdate: Date? private let batteryStatusUpdateInterval: TimeInterval = 20 + private let pmsetFetchQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.pmset", qos: .utility) + private var isPmsetRefreshInFlight = false + private var lastPmsetRefreshDate: Date? + private let pmsetRefreshCooldown: TimeInterval = 5 // MARK: - Initialization private init() { @@ -423,7 +427,7 @@ class BluetoothAudioManager: ObservableObject { triggerLiveBatteryRefreshIfNeeded() } - private func applyConnectedDeviceBatteryLevels() { + private func applyConnectedDeviceBatteryLevels(triggerPmsetFallback: Bool = true) { guard !connectedDevices.isEmpty else { lastConnectedDevice = nil return @@ -446,6 +450,11 @@ class BluetoothAudioManager: ObservableObject { if let last = updatedDevices.last { lastConnectedDevice = last } + + if triggerPmsetFallback, + updatedDevices.contains(where: { $0.batteryLevel == nil }) { + requestPmsetFallback(reason: "missing battery after refresh") + } } private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? { @@ -456,6 +465,44 @@ class BluetoothAudioManager: ObservableObject { ?? device.batteryLevel } + private func requestPmsetFallback(reason: String) { + guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } + guard !isPmsetRefreshInFlight else { return } + + let now = Date() + if let lastPmsetRefreshDate, + now.timeIntervalSince(lastPmsetRefreshDate) < pmsetRefreshCooldown { + return + } + + isPmsetRefreshInFlight = true + print("🎧 [BluetoothAudioManager] 🔄 Triggering pmset fallback (\(reason))") + pmsetFetchQueue.async { [weak self] in + guard let self else { return } + let entries = self.collectPmsetAccessoryBatteryEntries() + DispatchQueue.main.async { + self.handlePmsetFallbackResults(entries) + } + } + } + + private func handlePmsetFallbackResults(_ entries: [PmsetAccessoryBatteryEntry]) { + isPmsetRefreshInFlight = false + lastPmsetRefreshDate = Date() + guard !entries.isEmpty else { return } + + var updatedNames = batteryStatusByName + let newlyFilled = mergePmsetEntries(entries, into: &updatedNames, logNewEntries: true) + guard !newlyFilled.isEmpty else { return } + + batteryStatusByName = updatedNames + applyConnectedDeviceBatteryLevels(triggerPmsetFallback: false) + + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) + } + } + private func triggerLiveBatteryRefreshIfNeeded() { guard !connectedDevices.isEmpty else { return } guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } @@ -534,10 +581,8 @@ class BluetoothAudioManager: ObservableObject { guard didUpdate else { return } applyConnectedDeviceBatteryLevels() - if let lastLevel = lastConnectedDevice?.batteryLevel { - updateActiveBluetoothHUDBattery(with: lastLevel) - } else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel { - updateActiveBluetoothHUDBattery(with: fallbackLevel) + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) } } @@ -550,6 +595,11 @@ class BluetoothAudioManager: ObservableObject { } } + private func hudBatteryLevelCandidate() -> Int? { + lastConnectedDevice?.batteryLevel + ?? connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel + } + private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot { guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else { @@ -626,6 +676,46 @@ class BluetoothAudioManager: ObservableObject { static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:]) } + private struct PmsetAccessoryBatteryEntry { + let displayName: String + let normalizedName: String + let level: Int + } + + @discardableResult + private func mergePmsetEntries( + _ entries: [PmsetAccessoryBatteryEntry], + into names: inout [String: Int], + logNewEntries: Bool + ) -> [PmsetAccessoryBatteryEntry] { + guard !entries.isEmpty else { return [] } + + var newlyFilled: [PmsetAccessoryBatteryEntry] = [] + + for entry in entries { + let clamped = clampBatteryPercentage(entry.level) + let previous = names[entry.normalizedName] + + if previous == nil { + newlyFilled.append(entry) + names[entry.normalizedName] = clamped + continue + } + + if let previous, clamped > previous { + names[entry.normalizedName] = clamped + } + } + + if logNewEntries { + for entry in newlyFilled { + print("🎧 [BluetoothAudioManager] â„šī¸ pmset reported \(entry.level)% for \(entry.displayName)") + } + } + + return newlyFilled + } + private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { guard let address, !address.isEmpty else { return nil } guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } @@ -701,6 +791,9 @@ class BluetoothAudioManager: ObservableObject { mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses) mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names) + let pmsetEntries = collectPmsetAccessoryBatteryEntries() + mergePmsetEntries(pmsetEntries, into: &combinedNamePercentages, logNewEntries: true) + var statuses: [String: String] = [:] for (key, value) in combinedAddressPercentages { statuses[key] = String(clampBatteryPercentage(value)) @@ -867,6 +960,71 @@ class BluetoothAudioManager: ObservableObject { return (addressPercentages, namePercentages) } + private func collectPmsetAccessoryBatteryEntries() -> [PmsetAccessoryBatteryEntry] { + let process = Process() + process.launchPath = "/usr/bin/pmset" + process.arguments = ["-g", "accps"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + return [] + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return [] + } + + guard !data.isEmpty, let output = String(data: data, encoding: .utf8) else { + return [] + } + + guard let regex = try? NSRegularExpression( + pattern: #"^\s*-\s*(.+?)\s*(?:\(.+?\))?\s+(\d+)\s*%"#, + options: [.anchorsMatchLines] + ) else { + return [] + } + + var entries: [PmsetAccessoryBatteryEntry] = [] + let nsOutput = output as NSString + let range = NSRange(location: 0, length: nsOutput.length) + + regex.enumerateMatches(in: output, options: [], range: range) { match, _, _ in + guard let match, match.numberOfRanges >= 3 else { return } + + let rawName = nsOutput + .substring(with: match.range(at: 1)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let percentString = nsOutput.substring(with: match.range(at: 2)) + + guard !rawName.isEmpty, let level = Int(percentString) else { return } + + let normalizedName = normalizeProductName(rawName) + guard !normalizedName.isEmpty else { return } + if normalizedName.hasPrefix("internalbattery") { + return + } + + entries.append( + PmsetAccessoryBatteryEntry( + displayName: rawName, + normalizedName: normalizedName, + level: level + ) + ) + } + + return entries + } + private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] { var identifiers: Set = [] let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"] diff --git a/DynamicIsland/managers/ClipboardWindowManager.swift b/DynamicIsland/managers/ClipboardWindowManager.swift index b76941e..727f663 100644 --- a/DynamicIsland/managers/ClipboardWindowManager.swift +++ b/DynamicIsland/managers/ClipboardWindowManager.swift @@ -60,9 +60,12 @@ class ClipboardWindowManager: ObservableObject { window.contentView = hostingView // Handle window closing - window.delegate = WindowDelegate { [weak self] in + window.delegate = WindowDelegate { [weak self] window in + ScreenCaptureVisibilityManager.shared.unregister(window) self?.clipboardWindow = nil } + + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) self.clipboardWindow = window window.makeKeyAndOrderFront(nil) @@ -88,21 +91,17 @@ class ClipboardWindowManager: ObservableObject { } private class WindowDelegate: NSObject, NSWindowDelegate { - private let onClose: () -> Void - - init(onClose: @escaping () -> Void) { + private let onClose: (NSWindow) -> Void + + init(onClose: @escaping (NSWindow) -> Void) { self.onClose = onClose super.init() } - - func windowWillClose(_ notification: Notification) { - onClose() - } - + func windowShouldClose(_ sender: NSWindow) -> Bool { // Hide instead of close when user clicks close button sender.orderOut(nil) - onClose() + onClose(sender) return false } } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 2a50070..ef35ac4 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -70,6 +70,8 @@ class LockScreenLiveActivityWindowManager { window.alphaValue = 0 window.animationBehavior = .none + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + self.window = window self.hasDelegated = false return window diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index ca38307..e36adcf 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -11,6 +11,7 @@ import Combine import AppKit import Defaults import SwiftUI +import AVFoundation enum LockScreenAnimationTimings { static let lockExpand: TimeInterval = 0.45 @@ -80,6 +81,7 @@ class LockScreenManager: ObservableObject { @objc private func screenLocked() { print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received") + LockSoundPlayer.shared.playLockChime() // Update state SYNCHRONOUSLY without Task/await to avoid any delay lastUpdated = Date() @@ -120,6 +122,7 @@ class LockScreenManager: ObservableObject { @objc private func screenUnlocked() { print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received") + LockSoundPlayer.shared.playUnlockChime() // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: đŸšĒ Hiding panel window") @@ -190,3 +193,66 @@ extension LockScreenManager { return true // Always available on macOS } } + +// MARK: - Lock Sound Playback + +@MainActor +final class LockSoundPlayer { + static let shared = LockSoundPlayer() + private let throttleInterval: TimeInterval = 0.25 + private var players: [SoundType: AVAudioPlayer] = [:] + private var lastPlaybackDates: [SoundType: Date] = [:] + + private init() {} + + func playLockChime() { + play(.lock) + } + + func playUnlockChime() { + play(.unlock) + } + + private func play(_ type: SoundType) { + guard Defaults[.enableLockSounds] else { return } + guard shouldPlay(type) else { return } + guard let player = resolvePlayer(for: type) else { return } + + player.currentTime = 0 + player.play() + lastPlaybackDates[type] = Date() + } + + private func shouldPlay(_ type: SoundType) -> Bool { + guard let last = lastPlaybackDates[type] else { return true } + return Date().timeIntervalSince(last) >= throttleInterval + } + + private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? { + if let cached = players[type] { + return cached + } + + guard let url = Bundle.main.url(forResource: type.resourceName, withExtension: "mp3") else { + Logger.log("Missing \(type.resourceName).mp3 in bundle", category: .warning) + return nil + } + + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + players[type] = player + return player + } catch { + Logger.log("Failed to initialize lock sound player for \(type.resourceName): \(error.localizedDescription)", category: .error) + return nil + } + } + + private enum SoundType: String { + case lock + case unlock + + var resourceName: String { rawValue } + } +} diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 3e6eda8..e30d8bd 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -81,6 +81,8 @@ class LockScreenPanelManager { newWindow.isMovable = false newWindow.hasShadow = false + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + panelWindow = newWindow window = newWindow hasDelegated = false diff --git a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift index 5c66f75..4a82398 100644 --- a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift +++ b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift @@ -70,6 +70,8 @@ final class LockScreenReminderWidgetPanelManager { newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + window = newWindow if !hasDelegated { SkyLightOperator.shared.delegateWindow(newWindow) diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index f95df89..f4112f9 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -78,6 +78,8 @@ final class LockScreenWeatherPanelManager { newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + window = newWindow if !hasDelegated { SkyLightOperator.shared.delegateWindow(newWindow) diff --git a/DynamicIsland/managers/MusicControlWindowManager.swift b/DynamicIsland/managers/MusicControlWindowManager.swift index a7857b7..f72d59e 100644 --- a/DynamicIsland/managers/MusicControlWindowManager.swift +++ b/DynamicIsland/managers/MusicControlWindowManager.swift @@ -166,6 +166,8 @@ final class MusicControlWindowManager { window.isMovable = false window.alphaValue = 0 + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + self.window = window self.hasDelegated = false return window diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift index 5eed75f..eeb2378 100644 --- a/DynamicIsland/managers/TimerControlWindowManager.swift +++ b/DynamicIsland/managers/TimerControlWindowManager.swift @@ -145,6 +145,8 @@ final class TimerControlWindowManager { window.isMovable = false window.alphaValue = 0 + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + self.window = window self.hasDelegated = false return window diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 5ca47b7..9d7dc3c 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -383,7 +383,7 @@ extension Defaults.Keys { static let showOnAllDisplays = Key("showOnAllDisplays", default: false) static let automaticallySwitchDisplay = Key("automaticallySwitchDisplay", default: true) static let releaseName = Key("releaseName", default: "alpha 0.0.1") - static let hidePanelsFromScreenCapture = Key("hideFromScreenCapture", default: false) + static let hideDynamicIslandFromScreenCapture = Key("hideDynamicIslandFromScreenCapture", default: false) // MARK: Behavior static let minimumHoverDuration = Key("minimumHoverDuration", default: 0.3) @@ -601,6 +601,7 @@ extension Defaults.Keys { // MARK: Lock Screen Features static let enableLockScreenLiveActivity = Key("enableLockScreenLiveActivity", default: true) + static let enableLockSounds = Key("enableLockSounds", default: true) // MARK: ImageService static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 8a346ab..7d7937c 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -84,7 +84,11 @@ class DynamicIslandViewModel: NSObject, ObservableObject { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { - delegate.ensureWindowSize(updatedTarget, animated: true, force: false) + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) } } .store(in: &cancellables) @@ -106,7 +110,32 @@ class DynamicIslandViewModel: NSObject, ObservableObject { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { - delegate.ensureWindowSize(updatedTarget, animated: true, force: false) + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) + + coordinator.$statsSecondRowExpansion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.easeInOut(duration: 0.3)) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: false, + force: false + ) } } .store(in: &cancellables) @@ -167,7 +196,11 @@ class DynamicIslandViewModel: NSObject, ObservableObject { let applyWindowResize: () -> Void = { guard let delegate = AppDelegate.shared else { return } - delegate.ensureWindowSize(targetSize, animated: false, force: true) + delegate.ensureWindowSize( + addShadowPadding(to: targetSize, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: false, + force: true + ) } if Thread.isMainThread { @@ -187,28 +220,12 @@ class DynamicIslandViewModel: NSObject, ObservableObject { private func calculateDynamicNotchSize() -> CGSize { // Use minimalistic size if minimalistic UI is enabled - var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - // Only apply dynamic sizing when on stats tab and stats are enabled - guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { - return baseSize - } - - let enabledGraphsCount = [ - Defaults[.showCpuGraph], - Defaults[.showMemoryGraph], - Defaults[.showGpuGraph], - Defaults[.showNetworkGraph], - Defaults[.showDiskGraph] - ].filter { $0 }.count - - // If 4+ graphs are enabled, increase width - if enabledGraphsCount >= 4 { - let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120 - return CGSize(width: baseSize.width + extraWidth, height: baseSize.height) - } - - return baseSize + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + return statsAdjustedNotchSize( + from: baseSize, + isStatsTabActive: DynamicIslandViewCoordinator.shared.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) } func close() { diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 2e1ff7b..9166a96 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -17,6 +17,10 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticLyricsExtraHeight: CGFloat = 40 +let statsSecondRowContentHeight: CGFloat = 120 +let statsGridSpacingHeight: CGFloat = 12 +let notchShadowPaddingStandard: CGFloat = 18 +let notchShadowPaddingMinimalistic: CGFloat = 12 @MainActor var minimalisticOpenNotchSize: CGSize { @@ -37,6 +41,46 @@ var minimalisticOpenNotchSize: CGSize { let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) +func statsAdjustedNotchSize( + from baseSize: CGSize, + isStatsTabActive: Bool, + secondRowProgress: CGFloat +) -> CGSize { + guard isStatsTabActive, Defaults[.enableStatsFeature] else { + return baseSize + } + + let enabledGraphsCount = [ + Defaults[.showCpuGraph], + Defaults[.showMemoryGraph], + Defaults[.showGpuGraph], + Defaults[.showNetworkGraph], + Defaults[.showDiskGraph] + ].filter { $0 }.count + + guard enabledGraphsCount >= 4 else { + return baseSize + } + + let clampedProgress = max(0, min(secondRowProgress, 1)) + guard clampedProgress > 0 else { + return baseSize + } + + var adjustedSize = baseSize + let extraHeight = (statsSecondRowContentHeight + statsGridSpacingHeight) * clampedProgress + adjustedSize.height += extraHeight + return adjustedSize +} + +func notchShadowPaddingValue(isMinimalistic: Bool) -> CGFloat { + isMinimalistic ? notchShadowPaddingMinimalistic : notchShadowPaddingStandard +} + +func addShadowPadding(to size: CGSize, isMinimalistic: Bool) -> CGSize { + CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic)) +} + enum MusicPlayerImageSizes { static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20)) diff --git a/DynamicIsland/unlock.mp3 b/DynamicIsland/unlock.mp3 new file mode 100644 index 0000000..3b1b06d Binary files /dev/null and b/DynamicIsland/unlock.mp3 differ