From 97b30858ca907fa45ffe7237a41a6db948b38542 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 20 Dec 2025 13:39:13 +0530 Subject: [PATCH] Update Settings and Decoupled Bluetooth --- .../DynamicIslandViewCoordinator.swift | 8 +- DynamicIsland/Localizable.xcstrings | 23 +++- .../components/Settings/SettingsView.swift | 113 +++++++++++++----- .../managers/BluetoothAudioManager.swift | 8 ++ .../managers/SystemChangesObserver.swift | 3 +- .../managers/VerticalHUDWindowManager.swift | 50 +++++++- 6 files changed, 156 insertions(+), 49 deletions(-) diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 16813d3..4772ed8 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -164,11 +164,9 @@ class DynamicIslandViewCoordinator: ObservableObject { resolvedDuration = duration } sneakPeekDuration = resolvedDuration - if type != .music && type != .timer && type != .reminder { - // close() - if !Defaults[.enableSystemHUD] { - return - } + let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio] + if !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] { + return } DispatchQueue.main.async { withAnimation(.smooth) { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 900b5ec..e8c68cb 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3667,6 +3667,10 @@ "Battery fully charged" : { "comment" : "Battery is full" }, + "Battery Indicator Styling" : { + "comment" : "A section header in the Settings app that lists options for customizing the appearance of the battery indicator.", + "isCommentAutoGenerated" : true + }, "Battery Information" : { "localizations" : { "ar" : { @@ -5599,8 +5603,8 @@ "comment" : "An explanatory text that appears when the user tries to enable color-coded color gradients in Segmented progress bars. It instructs the user to switch to Hierarchical or Gradient progress bars to enable these features.", "isCommentAutoGenerated" : true }, - "Color-Coded Progress Bars" : { - "comment" : "A section header that lists the options for color-coded progress bars.", + "Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options." : { + "comment" : "A message explaining that color-coded battery indicators are unavailable in segmented progress bar style. It also includes a suggestion to switch to a more advanced progress bar style to adjust advanced options.", "isCommentAutoGenerated" : true }, "ColorPicker Disabled" : { @@ -6827,6 +6831,10 @@ } } }, + "Devices" : { + "comment" : "The title of the \"Devices\" section in the settings menu.", + "isCommentAutoGenerated" : true + }, "Diagnostics" : { "comment" : "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.", "isCommentAutoGenerated" : true @@ -7459,6 +7467,10 @@ } } }, + "Dynamic Island Progress Bars" : { + "comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.", + "isCommentAutoGenerated" : true + }, "dynamic.island" : { "localizations" : { "fr" : { @@ -10214,6 +10226,7 @@ } }, "HUDs" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10312,9 +10325,6 @@ } } } - }, - "HUDs & OSD" : { - }, "Icon & Progress Color" : { "comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.", @@ -17658,6 +17668,9 @@ "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." : { "comment" : "A description of how the color-coding feature works.", "isCommentAutoGenerated" : true + }, + "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island." : { + }, "Sneak peek duration" : { "comment" : "A label describing the duration of the \"sneak peek\" feature in the calendar settings.", diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2b56f9f..0accea7 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -23,6 +23,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case appearance case lockScreen case media + case devices case timer case calendar case hudAndOSD @@ -45,9 +46,10 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .appearance: return "Appearance" case .lockScreen: return "Lock Screen" case .media: return "Media" + case .devices: return "Devices" case .timer: return "Timer" case .calendar: return "Calendar" - case .hudAndOSD: return "HUDs & OSD" + case .hudAndOSD: return "Controls" case .battery: return "Battery" case .stats: return "Stats" case .clipboard: return "Clipboard" @@ -67,6 +69,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .appearance: return "paintpalette" case .lockScreen: return "lock.laptopcomputer" case .media: return "play.laptopcomputer" + case .devices: return "headphones" case .timer: return "timer" case .calendar: return "calendar" case .hudAndOSD: return "dial.medium.fill" @@ -89,6 +92,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .appearance: return .purple case .lockScreen: return .orange case .media: return .green + case .devices: return .cyan case .timer: return .red case .calendar: return .cyan case .hudAndOSD: return .indigo @@ -377,6 +381,7 @@ struct SettingsView: View { .appearance, .lockScreen, .media, + .devices, .timer, .calendar, .hudAndOSD, @@ -591,11 +596,11 @@ struct SettingsView: View { SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), // HUDs - SettingsSearchEntry(tab: .hudAndOSD, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show Bluetooth device connections")), - SettingsSearchEntry(tab: .hudAndOSD, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use circular battery indicator")), - SettingsSearchEntry(tab: .hudAndOSD, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show battery percentage text in HUD")), - SettingsSearchEntry(tab: .hudAndOSD, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Scroll device name in HUD")), - SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded battery display")), + SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), + SettingsSearchEntry(tab: .devices, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.devices.highlightID(for: "Use circular battery indicator")), + SettingsSearchEntry(tab: .devices, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.devices.highlightID(for: "Show battery percentage text in HUD")), + SettingsSearchEntry(tab: .devices, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.devices.highlightID(for: "Scroll device name in HUD")), + SettingsSearchEntry(tab: .devices, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.devices.highlightID(for: "Color-coded battery display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")), SettingsSearchEntry(tab: .hudAndOSD, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show percentages beside progress bars")), @@ -747,6 +752,10 @@ struct SettingsView: View { SettingsForm(tab: .media) { Media() } + case .devices: + SettingsForm(tab: .devices) { + DevicesSettingsView() + } case .timer: SettingsForm(tab: .timer) { TimerSettings() @@ -1572,7 +1581,7 @@ private struct HUDAndOSDSettingsView: View { .padding(.top, 10) } } - .navigationTitle("HUDs & OSD") + .navigationTitle("Controls") } } @@ -1629,6 +1638,63 @@ private struct HUDSelectionCard: View { } } +private struct DevicesSettingsView: View { + @Default(.progressBarStyle) var progressBarStyle + + private func highlightID(_ title: String) -> String { + SettingsTab.devices.highlightID(for: title) + } + + private var colorCodingDisabled: Bool { + progressBarStyle == .segmented + } + + var body: some View { + Form { + Section { + Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections) + .settingsHighlight(id: highlightID("Show Bluetooth device connections")) + Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator) + .settingsHighlight(id: highlightID("Use circular battery indicator")) + Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText) + .settingsHighlight(id: highlightID("Show battery percentage text in HUD")) + Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) + .settingsHighlight(id: highlightID("Scroll device name in HUD")) + } header: { + Text("Bluetooth Audio Devices") + } footer: { + Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.") + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay) + .disabled(colorCodingDisabled) + .settingsHighlight(id: highlightID("Color-coded battery display")) + } header: { + Text("Battery Indicator Styling") + } footer: { + if progressBarStyle == .segmented { + Text("Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options.") + .foregroundStyle(.secondary) + .font(.caption) + } else if Defaults[.useSmoothColorGradient] { + Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island.") + .foregroundStyle(.secondary) + .font(.caption) + } else { + Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).") + .foregroundStyle(.secondary) + .font(.caption) + } + } + } + .padding(.top, 10) + .navigationTitle("Devices") + } +} + struct HUD: View { @EnvironmentObject var vm: DynamicIslandViewModel @Default(.inlineHUD) var inlineHUD @@ -1648,6 +1714,10 @@ struct HUD: View { private var hasAccessibilityPermission: Bool { accessibilityPermission.isAuthorized } + + private var colorCodingDisabled: Bool { + progressBarStyle == .segmented + } var body: some View { Form { @@ -1678,29 +1748,8 @@ struct HUD: View { .font(.caption) } } - + Section { - Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections) - .settingsHighlight(id: highlightID("Show Bluetooth device connections")) - Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator) - .settingsHighlight(id: highlightID("Use circular battery indicator")) - Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText) - .settingsHighlight(id: highlightID("Show battery percentage text in HUD")) - Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) - .settingsHighlight(id: highlightID("Scroll device name in HUD")) - } header: { - Text("Bluetooth Audio Devices") - } footer: { - Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.") - .foregroundStyle(.secondary) - .font(.caption) - } - - Section { - let colorCodingDisabled = progressBarStyle == .segmented - Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay) - .disabled(colorCodingDisabled) - .settingsHighlight(id: highlightID("Color-coded battery display")) Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay) .disabled(colorCodingDisabled) .settingsHighlight(id: highlightID("Color-coded volume display")) @@ -1713,9 +1762,9 @@ struct HUD: View { Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages) .settingsHighlight(id: highlightID("Show percentages beside progress bars")) } header: { - Text("Color-Coded Progress Bars") + Text("Dynamic Island Progress Bars") } footer: { - if progressBarStyle == .segmented { + if colorCodingDisabled { Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.") .foregroundStyle(.secondary) .font(.caption) @@ -1765,7 +1814,7 @@ struct HUD: View { } } } - .navigationTitle("HUDs") + .navigationTitle("Controls") .onAppear { accessibilityPermission.refreshStatus() } diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index ff8da1a..ffa8c3d 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -1456,6 +1456,14 @@ class BluetoothAudioManager: ObservableObject { func refreshConnectedDeviceBatteries() { refreshBatteryLevelsForConnectedDevices() } + + @MainActor + func activeDeviceIconSymbol() -> String? { + if let prioritizedDevice = connectedDevices.last ?? lastConnectedDevice { + return prioritizedDevice.deviceType.sfSymbol + } + return nil + } } // MARK: - CoreBluetooth Battery Reader diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift index d1cba2c..c62cb11 100644 --- a/DynamicIsland/managers/SystemChangesObserver.swift +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -178,7 +178,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { // Send to Vertical HUD if enabled if Defaults[.enableVerticalHUD] { Task { @MainActor in - VerticalHUDWindowManager.shared.show(type: .volume, value: CGFloat(value)) + let icon = BluetoothAudioManager.shared.activeDeviceIconSymbol() ?? "" + VerticalHUDWindowManager.shared.show(type: .volume, value: CGFloat(value), icon: icon) } return } diff --git a/DynamicIsland/managers/VerticalHUDWindowManager.swift b/DynamicIsland/managers/VerticalHUDWindowManager.swift index 0bb820c..cdff521 100644 --- a/DynamicIsland/managers/VerticalHUDWindowManager.swift +++ b/DynamicIsland/managers/VerticalHUDWindowManager.swift @@ -42,6 +42,12 @@ final class VerticalHUDWindowManager { Defaults.publisher(.verticalHUDWidth, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDPadding, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDInteractive, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + + Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in + Task { @MainActor [weak self] in + self?.handleEnablementChange(change.newValue) + } + }.store(in: &cancellables) } private func updateWindowLayout() { @@ -53,7 +59,6 @@ final class VerticalHUDWindowManager { let width = Defaults[.verticalHUDWidth] let height = Defaults[.verticalHUDHeight] let padding = Defaults[.verticalHUDPadding] - let interactive = Defaults[.verticalHUDInteractive] // Add padding for shadow and elastic stretch // We use 120px total padding (60px per side) to absolutely guarantee no clipping. @@ -77,9 +82,41 @@ final class VerticalHUDWindowManager { let newFrame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight) window.nsWindow.setFrame(newFrame, display: true, animate: true) - window.nsWindow.ignoresMouseEvents = !interactive + applyInteractivity(window) } } + + private func handleEnablementChange(_ isEnabled: Bool) { + if isEnabled { + updateWindowLayout() + } else { + teardownWindows() + } + } + + private func applyInteractivity(_ window: OSDWindow, visibleOverride: Bool? = nil) { + let isVisible = visibleOverride ?? (window.nsWindow.alphaValue > 0.01) + let shouldAllowInteraction = Defaults[.enableVerticalHUD] && Defaults[.verticalHUDInteractive] && isVisible + window.nsWindow.ignoresMouseEvents = !shouldAllowInteraction + } + + private func updateWindowInteractivity() { + for window in windows.values { + applyInteractivity(window) + } + } + + private func teardownWindows() { + hideWorkItem?.cancel() + hideWorkItem = nil + + for window in windows.values { + window.nsWindow.orderOut(nil) + window.nsWindow.ignoresMouseEvents = true + } + + windows.removeAll() + } func show(type: SneakContentType, value: CGFloat, icon: String = "") { guard Defaults[.enableVerticalHUD] else { return } @@ -94,6 +131,7 @@ final class VerticalHUDWindowManager { // Ensure visible windowContext.nsWindow.orderFrontRegardless() + applyInteractivity(windowContext, visibleOverride: true) NSAnimationContext.runAnimationGroup { context in context.duration = animationDuration @@ -161,8 +199,8 @@ final class VerticalHUDWindowManager { win.contentView = hostingView win.alphaValue = 0 - // Allow mouse interaction if enabled - win.ignoresMouseEvents = !Defaults[.verticalHUDInteractive] + // Default to ignoring events until explicitly shown + win.ignoresMouseEvents = true SkyLightOperator.shared.delegateWindow(win) @@ -197,8 +235,8 @@ final class VerticalHUDWindowManager { NSAnimationContext.runAnimationGroup { context in context.duration = animationDuration window.nsWindow.animator().alphaValue = 0 - } completionHandler: { - // Keep window around + } completionHandler: { [weak self] in + self?.applyInteractivity(window, visibleOverride: false) } } }