diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d41a940 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/assets/lockscreen.gif b/.github/assets/lockscreen.gif new file mode 100644 index 0000000..e735751 --- /dev/null +++ b/.github/assets/lockscreen.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220 +size 90875738 diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index e6c6510..7566c97 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -401,6 +401,8 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DynamicIsland/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Atoll; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch"; INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; @@ -412,7 +414,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "1.0.1-beta"; + MARKETING_VERSION = "1.0.2-beta"; PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -449,6 +451,8 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DynamicIsland/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Atoll; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch"; INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; @@ -460,7 +464,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "1.0.1-beta"; + MARKETING_VERSION = "1.0.2-beta"; PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index e5c6517..b908f3f 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -200,14 +200,14 @@ struct ContentView: View { } .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive { + if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive { vm.close() } } } .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { + if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive { vm.close() } } @@ -215,7 +215,7 @@ struct ContentView: View { .onChange(of: vm.shouldRecheckHover) { _, _ in // Recheck hover state when popovers are closed DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering { + if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering { vm.close() } } @@ -320,10 +320,9 @@ struct ContentView: View { } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { - SettingsLink(label: { - Text("Settings") - }) - .keyboardShortcut(KeyEquivalent(","), modifiers: .command) + Button("Settings") { + SettingsWindowController.shared.showWindow() + } // Button("Edit") { // Doesnt work.... // let dn = DynamicNotch(content: EditPanelView()) // dn.toggle() @@ -404,7 +403,7 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) { + } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { @@ -429,7 +428,7 @@ struct ContentView: View { } if coordinator.sneakPeek.show { - if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] { + if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // }) @@ -751,10 +750,11 @@ struct ContentView: View { // Helper function to check if any popovers are active private func hasAnyActivePopovers() -> Bool { - return vm.isBatteryPopoverActive || - vm.isClipboardPopoverActive || - vm.isColorPickerPopoverActive || - vm.isStatsPopoverActive + return vm.isBatteryPopoverActive || + vm.isClipboardPopoverActive || + vm.isColorPickerPopoverActive || + vm.isStatsPopoverActive || + vm.isMediaOutputPopoverActive } // Helper to prevent rapid haptic feedback diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 384401b..b756100 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -70,6 +70,15 @@ struct DynamicNotchApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) } + + var commands: some Commands { + CommandGroup(replacing: .appSettings) { + Button("Settings…") { + SettingsWindowController.shared.showWindow() + } + .keyboardShortcut(",", modifiers: .command) + } + } } class AppDelegate: NSObject, NSApplicationDelegate { @@ -404,6 +413,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in self?.debouncedUpdateWindowSize() }.store(in: &cancellables) + + Defaults.publisher(.enableShortcuts, options: []).sink { change in + KeyboardShortcuts.isEnabled = change.newValue + }.store(in: &cancellables) // Observe minimalistic UI setting changes - trigger window resize Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in @@ -477,12 +490,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { self, selector: #selector(onScreenUnlocked(_:)), name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil) - KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in + _ = KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in guard let self = self else { return } - - // Only execute if shortcuts are enabled guard Defaults[.enableShortcuts] else { return } - + self.coordinator.toggleSneakPeek( status: !self.coordinator.sneakPeek.show, type: .music, @@ -490,14 +501,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } - KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in + _ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in guard let self = self else { return } - - // Only execute if shortcuts are enabled guard Defaults[.enableShortcuts] else { return } - + let mouseLocation = NSEvent.mouseLocation - + var viewModel = self.vm if Defaults[.showOnAllDisplays] { @@ -510,131 +519,91 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } - + self.closeNotchWorkItem?.cancel() self.closeNotchWorkItem = nil - + switch viewModel.notchState { case .closed: viewModel.open() - + let workItem = DispatchWorkItem { [weak viewModel] in viewModel?.close() } self.closeNotchWorkItem = workItem - + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem) case .open: viewModel.close() } } - - KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in - guard let self = self else { return } - - // Only execute if shortcuts are enabled + + _ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { guard Defaults[.enableShortcuts] else { return } - - // Only start timer if the timer feature is enabled guard Defaults[.enableTimerFeature] else { return } - - // Start a 5-minute demo timer TimerManager.shared.startDemoTimer(duration: 300) } - - KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in + + _ = KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in guard let self = self else { return } - - // Only execute if shortcuts are enabled guard Defaults[.enableShortcuts] else { return } - - // Only open clipboard if the feature is enabled guard Defaults[.enableClipboardManager] else { return } - - // Start clipboard monitoring if not already running + if !ClipboardManager.shared.isMonitoring { ClipboardManager.shared.startMonitoring() } - - // Handle keyboard shortcut based on display mode + switch Defaults[.clipboardDisplayMode] { case .panel: ClipboardPanelManager.shared.toggleClipboardPanel() case .popover: - // For popover mode, first ensure notch is open, then toggle popover - - // If notch is closed, open it first if self.vm.notchState == .closed { self.vm.open() - // Wait a moment for the notch to open, then show popover DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) } } else { - // Notch is already open, toggle popover immediately NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) } } } - - KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in - guard let self = self else { return } - - // Only execute if shortcuts are enabled + + _ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { guard Defaults[.enableShortcuts] else { return } - - // Only open color picker panel if the feature is enabled guard Defaults[.enableColorPickerFeature] else { return } - - // Toggle color picker panel ColorPickerPanelManager.shared.toggleColorPickerPanel() } - - KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in + + _ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in guard let self = self else { return } - - // Only execute if shortcuts are enabled guard Defaults[.enableShortcuts] else { return } - - // Only open screen assistant if the feature is enabled guard Defaults[.enableScreenAssistant] else { return } - - // Handle keyboard shortcut based on display mode + switch Defaults[.screenAssistantDisplayMode] { case .panel: ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel() case .popover: - // For popover mode, first ensure notch is open, then toggle popover - - // If notch is closed, open it first if self.vm.notchState == .closed { self.vm.open() - // Wait a moment for the notch to open, then show popover DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil) } } else { - // Notch is already open, toggle popover immediately NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil) } } } - - KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in - guard let self = self else { return } - - // Only execute if shortcuts are enabled + + KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts] + + _ = KeyboardShortcuts.onKeyDown(for: .statsPanel) { guard Defaults[.enableShortcuts] else { return } - - // Only open stats panel if both stats feature and panel are enabled guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return } - - // Start stats monitoring if not already running + if !StatsManager.shared.isMonitoring { StatsManager.shared.startMonitoring() } - - // Toggle stats panel + StatsPanelManager.shared.toggleStatsPanel() } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index a9f0a7d..c83114d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3157,6 +3157,15 @@ } } }, + "Battery charging" : { + "comment" : "Battery charging without estimate" + }, + "Battery charging, %@ remaining" : { + "comment" : "Charging time remaining" + }, + "Battery fully charged" : { + "comment" : "Battery is full" + }, "Battery Information" : { "localizations" : { "ar" : { @@ -3464,6 +3473,17 @@ "comment" : "A section header that lists Bluetooth audio devices.", "isCommentAutoGenerated" : true }, + "Bluetooth device %@ at %d percent" : { + "comment" : "Bluetooth device battery", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bluetooth device %1$@ at %2$d percent" + } + } + } + }, "Boost your productivity with Clipboard Manager" : { "localizations" : { "ar" : { @@ -4609,8 +4629,9 @@ "Color Picker Panel:" : { }, - "Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature." : { - + "Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options." : { + "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.", @@ -4721,9 +4742,6 @@ }, "Configuration" : { - }, - "Connected" : { - }, "Continue" : { "localizations" : { @@ -5800,8 +5818,8 @@ } } }, - "Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)" : { - "comment" : "A description of the \"Discrete\" color-coded progress bar style, including the colors used for battery and volume levels.", + "Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : { + "comment" : "A description of how the color-coded progress bars work when not using smooth transitions.", "isCommentAutoGenerated" : true }, "Disk Read" : { @@ -10356,6 +10374,10 @@ } } }, + "Media output" : { + "comment" : "Accessibility label describing the control that opens the media output selector.", + "isCommentAutoGenerated" : true + }, "Media playback live activity" : { "localizations" : { "ar" : { @@ -10761,6 +10783,7 @@ } }, "Mic %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10860,6 +10883,14 @@ } } }, + "Mic muted" : { + "comment" : "A description of the state when the microphone is muted.", + "isCommentAutoGenerated" : true + }, + "Mic unmuted" : { + "comment" : "A description of the state when the microphone is unmuted.", + "isCommentAutoGenerated" : true + }, "Microphone" : { "comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.", "isCommentAutoGenerated" : true @@ -11805,6 +11836,10 @@ "comment" : "A message displayed when there are no active network interfaces to display.", "isCommentAutoGenerated" : true }, + "No audio outputs available" : { + "comment" : "Displayed when no audio output devices can be listed in the media output selector.", + "isCommentAutoGenerated" : true + }, "No clipboard history" : { }, @@ -13005,6 +13040,14 @@ }, "Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : { + }, + "Output devices" : { + "comment" : "Section title for the list of available audio output devices in the media output selector.", + "isCommentAutoGenerated" : true + }, + "Output volume" : { + "comment" : "Label describing the system volume control inside the media output selector.", + "isCommentAutoGenerated" : true }, "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { @@ -13743,6 +13786,10 @@ }, "Reminders" : { + }, + "Replace repeat button with media output control" : { + "comment" : "A toggle that allows users to replace the repeat button with a media output control.", + "isCommentAutoGenerated" : true }, "Replaces system HUD notifications with Dynamic Island displays." : { @@ -14465,6 +14512,10 @@ } } }, + "Settings…" : { + "comment" : "A command that shows the settings window.", + "isCommentAutoGenerated" : true + }, "Shelf" : { "localizations" : { "ar" : { @@ -15307,8 +15358,8 @@ } } }, - "Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)" : { - "comment" : "A detailed description of the \"Smooth\" color-coded gradient display feature.", + "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 }, "Sneak Peek shows the media title and artist under the notch for a few seconds." : { diff --git a/DynamicIsland/components/ColorCodedProgressBar.swift b/DynamicIsland/components/ColorCodedProgressBar.swift index 6698aae..8d620bc 100644 --- a/DynamicIsland/components/ColorCodedProgressBar.swift +++ b/DynamicIsland/components/ColorCodedProgressBar.swift @@ -9,16 +9,31 @@ import SwiftUI import Defaults +enum ColorCodingMode { + case volume + case battery + + func intensity(for value: CGFloat) -> CGFloat { + let clamped = min(max(value, 0), 1) + switch self { + case .volume: + return clamped + case .battery: + return 1 - clamped + } + } +} + struct ColorCodedProgressBar: View { let value: CGFloat // 0.0 to 1.0 - let reversed: Bool // If true: green at low, red at high (for volume) + let mode: ColorCodingMode let width: CGFloat let height: CGFloat let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors - init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) { + init(value: CGFloat, mode: ColorCodingMode = .battery, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) { self.value = min(max(value, 0), 1) // Clamp between 0 and 1 - self.reversed = reversed + self.mode = mode self.width = width self.height = height self.smoothGradient = smoothGradient @@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View { // Filled track with color gradient or solid color RoundedRectangle(cornerRadius: height / 2) - .fill(smoothGradient ? - AnyShapeStyle(LinearGradient( - gradient: gradientForValue, - startPoint: .leading, - endPoint: .trailing - )) : - AnyShapeStyle(colorForPercentage(value, reversed: reversed)) - ) + .fill(fillStyle) .frame(width: width * value, height: height) } } .frame(width: width, height: height) } - /// Generates a gradient based on the current value - private var gradientForValue: Gradient { - let color = colorForPercentage(value, reversed: reversed) - - // Create a gradient that transitions smoothly - let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed) - let endColor = color - - return Gradient(colors: [startColor, endColor]) - } - - /// Calculates discrete color for a given percentage (no smooth transitions) - /// - Parameters: - /// - percentage: Value from 0.0 to 1.0 - /// - reversed: If true, reverses the color scale (green at low, red at high) - /// - Returns: Discrete color (red, yellow, or green only) - private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color { - let normalized = reversed ? (1.0 - percentage) : percentage - - switch normalized { - case 0.0..<0.33: - // Red zone (critical/low) - return Color(red: 1.0, green: 0.23, blue: 0.19) - case 0.33..<0.67: - // Yellow zone (medium) - return Color(red: 1.0, green: 0.8, blue: 0.0) - default: - // Green zone (healthy/high) - return Color(red: 0.2, green: 0.78, blue: 0.35) - } - } - - /// Calculates color for a given percentage with smooth transitions - /// - Parameters: - /// - percentage: Value from 0.0 to 1.0 - /// - reversed: If true, reverses the color scale (green at low, red at high) - /// - Returns: Color with smooth gradient transitions - private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color { - let normalized = reversed ? (1.0 - percentage) : percentage - - switch normalized { - case 0.0..<0.2: - // Pure Red zone (critical/low) - return Color(red: 1.0, green: 0.23, blue: 0.19) - - case 0.2..<0.35: - // Red → Orange transition - let t = (normalized - 0.2) / 0.15 - let red: CGFloat = 1.0 - let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6 - let blue: CGFloat = 0.19 - return Color(red: red, green: green, blue: blue) - - case 0.35..<0.5: - // Orange → Yellow transition - let t = (normalized - 0.35) / 0.15 - let red: CGFloat = 1.0 - let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0 - let blue: CGFloat = 0.19 - return Color(red: red, green: green, blue: blue) - - case 0.5..<0.65: - // Yellow → Yellow-Green transition - let t = (normalized - 0.5) / 0.15 - let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6 - let green: CGFloat = 1.0 - let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59 - return Color(red: red, green: green, blue: blue) - - case 0.65..<0.8: - // Yellow-Green → Green transition - let t = (normalized - 0.65) / 0.15 - let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2 - let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78 - let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35 - return Color(red: red, green: green, blue: blue) - - default: - // Pure Green zone (healthy/high) - return Color(red: 0.2, green: 0.78, blue: 0.35) + private var fillStyle: AnyShapeStyle { + let intensity = mode.intensity(for: value) + if smoothGradient { + return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity)) + } else { + return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity)) } } } @@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View { extension ColorCodedProgressBar { /// Creates a battery-style progress bar (green at high, red at low) static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View { - ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient) + ColorCodedProgressBar(value: value, mode: .battery, width: width, height: height, smoothGradient: smoothGradient) } /// Creates a volume-style progress bar (red at high, green at low) static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View { - ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient) + ColorCodedProgressBar(value: value, mode: .volume, width: width, height: height, smoothGradient: smoothGradient) + } + + /// Shared helper for other progress bar implementations that need the same palette. + static func shapeStyle(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> AnyShapeStyle { + let intensity = mode.intensity(for: min(max(value, 0), 1)) + if smoothGradient { + return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity)) + } + return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity)) + } + + /// Returns the palette color that matches the configured thresholds. Useful for non-linear indicators. + static func paletteColor(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> Color { + let normalized = min(max(value, 0), 1) + let intensity = mode.intensity(for: normalized) + return ColorCodedPalette.color(for: intensity, smooth: smoothGradient) + } +} + +// MARK: - Palette Helpers + +private enum ColorCodedPalette { + private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35) + private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2) + private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3) + + static func color(for intensity: CGFloat, smooth: Bool) -> Color { + if smooth { + return smoothColor(for: intensity) + } + return discreteColor(for: intensity) + } + + static func discreteColor(for intensity: CGFloat) -> Color { + let clamped = min(max(intensity, 0), 1) + switch clamped { + case ..<0.6: + return color(from: greenComponents) + case ..<0.85: + return color(from: yellowComponents) + default: + return color(from: redComponents) + } + } + + static func gradient(for intensity: CGFloat) -> LinearGradient { + let endColor = smoothColor(for: intensity) + let startColor = smoothColor(for: max(intensity - 0.15, 0)) + return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing) + } + + private static func smoothColor(for intensity: CGFloat) -> Color { + let clamped = min(max(intensity, 0), 1) + switch clamped { + case ..<0.6: + // Stay green within the comfort zone + return color(from: greenComponents) + case ..<0.85: + let t = (clamped - 0.6) / 0.25 + return blend(from: greenComponents, to: yellowComponents, fraction: t) + default: + let t = (clamped - 0.85) / 0.15 + return blend(from: yellowComponents, to: redComponents, fraction: t) + } + } + + private static func blend(from start: (CGFloat, CGFloat, CGFloat), to end: (CGFloat, CGFloat, CGFloat), fraction: CGFloat) -> Color { + let t = min(max(fraction, 0), 1) + let red = lerp(start.0, end.0, t) + let green = lerp(start.1, end.1, t) + let blue = lerp(start.2, end.2, t) + return color(from: (red, green, blue)) + } + + private static func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat { + a + (b - a) * t + } + + private static func color(from components: (CGFloat, CGFloat, CGFloat)) -> Color { + Color(red: Double(components.0), green: Double(components.1), blue: Double(components.2)) } } diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index 13d6c6d..e721a41 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -20,12 +20,42 @@ struct InlineHUD: View { @Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay @Default(.useSmoothColorGradient) var useSmoothColorGradient @Default(.progressBarStyle) var progressBarStyle + @Default(.showProgressPercentages) var showProgressPercentages + @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator + @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText + @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee @ObservedObject var bluetoothManager = BluetoothAudioManager.shared @State private var displayName: String = "" var body: some View { - HStack { + let useCircularIndicator = useCircularBluetoothBatteryIndicator + let hasBatteryLevel = value > 0 + + let baseInfoWidth: CGFloat = { + if type == .bluetoothAudio { + return showBluetoothDeviceNameMarquee ? 100 : 52 + } + return 100 + }() + let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2 + let baseTrailingWidth: CGFloat = { + if type == .bluetoothAudio { + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? 100 : 70 + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? 100 : 70 + } else { + return showBluetoothBatteryPercentageText ? 112 : 82 + } + } + return 100 + }() + let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2 + + return HStack { HStack(spacing: 5) { Group { switch (type) { @@ -77,22 +107,15 @@ struct InlineHUD: View { // Use marquee text for device names to handle long names if type == .bluetoothAudio { - MarqueeText( - $displayName, - font: .system(size: 13, weight: .medium), - nsFont: .body, - textColor: .white, - minDuration: 0.5, - frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2 - ) - .onAppear { - displayName = Type2Name(type) - } - .onChange(of: type) { _, _ in - displayName = Type2Name(type) - } - .onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in - displayName = Type2Name(type) + if showBluetoothDeviceNameMarquee { + MarqueeText( + $displayName, + font: .system(size: 13, weight: .medium), + nsFont: .body, + textColor: .white, + minDuration: 0.2, + frameWidth: infoWidth + ) } } else { Text(Type2Name(type)) @@ -103,7 +126,7 @@ struct InlineHUD: View { .contentTransition(.numericText()) } } - .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading) + .frame(width: infoWidth, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading) Rectangle() .fill(.black) @@ -127,62 +150,147 @@ struct InlineHUD: View { .frame(maxWidth: .infinity, alignment: .trailing) .contentTransition(.interpolate) } else if (type == .bluetoothAudio) { - // Bluetooth device battery display - HStack(spacing: 4) { - if value > 0 { - // Show color-coded battery bar if battery info available - // Color-coding disabled for segmented or hierarchical mode - if useColorCodedBatteryDisplay && progressBarStyle == .gradient { - ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient) + if hasBatteryLevel { + HStack(spacing: useCircularIndicator ? 8 : 6) { + if useCircularIndicator { + CircularBatteryIndicator( + value: value, + useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented, + smoothGradient: useSmoothColorGradient + ) + .allowsHitTesting(false) } else { - DraggableProgressBar(value: .constant(value)) - .frame(width: 60) + LinearBatteryIndicator( + value: value, + useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented, + smoothGradient: useSmoothColorGradient + ) + .allowsHitTesting(false) } - Text("\(Int(value * 100))%") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.white) - .lineLimit(1) + + if showBluetoothBatteryPercentageText { + Text("\(Int(value * 100))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } else { + // Volume and brightness displays + Group { + if type == .volume { + Group { + if value.isZero { + Text("muted") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.gray) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + .contentTransition(.numericText()) + } else { + HStack(spacing: 6) { + DraggableProgressBar(value: $value, colorMode: .volume) + PercentageLabel(value: value, isVisible: showProgressPercentages) + } + .transition(.opacity.combined(with: .scale)) + } + } + .animation(.smooth(duration: 0.2), value: value.isZero) } else { - // No battery info available - Text("Connected") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.gray) - .lineLimit(1) + HStack(spacing: 6) { + DraggableProgressBar(value: $value) + PercentageLabel(value: value, isVisible: showProgressPercentages) + } } } .frame(maxWidth: .infinity, alignment: .trailing) - } else { - // Volume and brightness displays - HStack { - // Color-coding only available in gradient mode, not hierarchical or segmented - if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient { - // Color-coded volume (reversed: red at high, green at low) - ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient) - } else { - // Default draggable progress bar - DraggableProgressBar(value: $value) - } - - if (type == .volume && value.isZero) { - Text("muted") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.gray) - .lineLimit(1) - .allowsTightening(true) - .multilineTextAlignment(.trailing) - } - } } } .padding(.trailing, 4) - .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) + .frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) } .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) + .onAppear { + displayName = Type2Name(type) + } + .onChange(of: type) { _, _ in + displayName = Type2Name(type) + } + .onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in + displayName = Type2Name(type) + } } + private struct CircularBatteryIndicator: View { + let value: CGFloat + let useColorCoding: Bool + let smoothGradient: Bool + + private var clampedValue: CGFloat { + min(max(value, 0), 1) + } + + private var indicatorColor: Color { + if useColorCoding { + return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient) + } + return .white + } + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: 2.6) + + Circle() + .trim(from: 0, to: max(clampedValue, 0.015)) + .rotation(.degrees(-90)) + .stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round)) + } + .frame(width: 22, height: 22) + .animation(.smooth(duration: 0.18), value: clampedValue) + } + } + + private struct LinearBatteryIndicator: View { + let value: CGFloat + let useColorCoding: Bool + let smoothGradient: Bool + + private let trackWidth: CGFloat = 54 + private let trackHeight: CGFloat = 6 + + private var clampedValue: CGFloat { + min(max(value, 0), 1) + } + + private var fillColor: Color { + if useColorCoding { + return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient) + } + return .white + } + + var body: some View { + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.18)) + .frame(width: trackWidth, height: trackHeight) + + Capsule() + .fill(fillColor) + .frame(width: trackWidth * clampedValue, height: trackHeight) + } + .frame(width: trackWidth, height: trackHeight) + .animation(.smooth(duration: 0.18), value: clampedValue) + } + } + func SpeakerSymbol(_ value: CGFloat) -> String { switch(value) { case 0: diff --git a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift index 911293b..97cceab 100644 --- a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift +++ b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift @@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View { @Binding var icon: String let showSlider: Bool = false var sendEventBack: (CGFloat) -> Void + @Default(.showProgressPercentages) private var showProgressPercentages var body: some View { HStack(spacing: 14) { @@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View { default: EmptyView() } - if (eventType != .mic) { - DraggableProgressBar(value: $value) - } else { - Text("Mic \(value > 0 ? "unmuted" : "muted")") + switch eventType { + case .mic: + Text(value > 0 ? "Mic unmuted" : "Mic muted") .foregroundStyle(.gray) .lineLimit(1) .allowsTightening(true) + .contentTransition(.numericText()) + case .volume: + VolumeProgressSection(value: $value, showPercentages: showProgressPercentages) + case .brightness: + ProgressSection(value: $value, showPercentages: showProgressPercentages) + case .backlight: + ProgressSection(value: $value, showPercentages: showProgressPercentages) + default: + ProgressSection(value: $value, showPercentages: showProgressPercentages) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -88,18 +97,106 @@ struct SystemEventIndicatorModifier: View { } } +struct ProgressSection: View { + @Binding var value: CGFloat + var showPercentages: Bool + var colorMode: ProgressColorMode? = nil + + var body: some View { + HStack(spacing: 8) { + DraggableProgressBar(value: $value, colorMode: colorMode) + PercentageLabel(value: value, isVisible: showPercentages) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct VolumeProgressSection: View { + @Binding var value: CGFloat + var showPercentages: Bool + + var body: some View { + Group { + if value.isZero { + Text("muted") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.gray) + .contentTransition(.numericText()) + } else { + ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume) + .transition(.opacity.combined(with: .scale)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.smooth(duration: 0.2), value: value.isZero) + } +} + +struct PercentageLabel: View { + let value: CGFloat + let isVisible: Bool + @Default(.progressBarStyle) private var progressBarStyle + + var body: some View { + Group { + if shouldShow { + Text(formattedValue) + .font(.caption) + .fontWeight(.semibold) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + .contentTransition(.numericText()) + .frame(width: 28, alignment: .trailing) + } + } + } + + private var shouldShow: Bool { + isVisible && progressBarStyle != .segmented + } + + private var formattedValue: String { + let raw = max(0, min(100, Int(round(value * 100)))) + return String(format: "%3d", raw) + } +} + +enum ProgressColorMode { + case volume + case battery +} + +private extension ProgressColorMode { + var colorCodingMode: ColorCodingMode { + switch self { + case .volume: + return .volume + case .battery: + return .battery + } + } +} + struct DraggableProgressBar: View { @EnvironmentObject var vm: DynamicIslandViewModel @Binding var value: CGFloat + var colorMode: ProgressColorMode? = nil @State private var isDragging = false - @State private var dragOffset: CGFloat = 0 + @Default(.progressBarStyle) private var progressBarStyle + @Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay + @Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay + @Default(.useSmoothColorGradient) private var useSmoothColorGradient + @Default(.systemEventIndicatorUseAccent) private var useAccentColor + @Default(.systemEventIndicatorShadow) private var useShadow + @Default(.inlineHUD) private var inlineHUD var body: some View { VStack { GeometryReader { geo in Group { - if Defaults[.progressBarStyle] == .segmented { + if progressBarStyle == .segmented { // Segmented progress bar - completely different layout SegmentedProgressContent(value: value, geometry: geo) } else { @@ -107,23 +204,8 @@ struct DraggableProgressBar: View { ZStack(alignment: .leading) { Capsule() .fill(.tertiary) - Group { - switch Defaults[.progressBarStyle] { - case .gradient: - Capsule() - .fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading)) - .frame(width: max(0, min(geo.size.width * value, geo.size.width))) - .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3) - case .hierarchical: - Capsule() - .fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white) - .frame(width: max(0, min(geo.size.width * value, geo.size.width))) - .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3) - case .segmented: - EmptyView() // This case won't be reached due to the outer if condition - } - } - .opacity(value.isZero ? 0 : 1) + progressFill(width: geo.size.width) + .opacity(value.isZero ? 0 : 1) } } } @@ -142,7 +224,7 @@ struct DraggableProgressBar: View { } ) } - .frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6) + .frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6)) } } @@ -152,6 +234,59 @@ struct DraggableProgressBar: View { value = max(0, min(newValue, 1)) } + + private func progressFill(width: CGFloat) -> some View { + let clampedWidth = max(0, min(width * value, width)) + let accentColor = Defaults[.accentColor] + let shadowColor: Color = { + guard useShadow else { return .clear } + if useAccentColor { + return accentColor.ensureMinimumBrightness(factor: 0.7) + } + return .white + }() + + return Group { + if shouldUseColorCoding, let mode = colorMode?.colorCodingMode { + Capsule() + .fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient)) + .frame(width: clampedWidth) + .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) + } else { + switch progressBarStyle { + case .gradient: + Capsule() + .fill(LinearGradient( + colors: useAccentColor + ? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)] + : [.white, .white.opacity(0.2)], + startPoint: .trailing, + endPoint: .leading + )) + .frame(width: clampedWidth) + .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) + case .hierarchical: + Capsule() + .fill(useAccentColor ? accentColor : .white) + .frame(width: clampedWidth) + .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) + case .segmented: + EmptyView() + } + } + } + } + + private var shouldUseColorCoding: Bool { + guard let colorMode else { return false } + guard progressBarStyle != .segmented else { return false } + switch colorMode { + case .volume: + return useColorCodedVolumeDisplay + case .battery: + return useColorCodedBatteryDisplay + } + } } struct SegmentedProgressContent: View { @@ -164,8 +299,8 @@ struct SegmentedProgressContent: View { var body: some View { let spacing: CGFloat = 1.5 - let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount) - let barWidth = max(3.0, min(6.0, computed)) + let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount) + let barWidth = max(3.0, computed) let activeCount = Int(round(value * CGFloat(segmentCount))) HStack(spacing: spacing) { diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index b539d8b..a351e66 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View { static let expandedSize = CGSize(width: 720, height: 340) @ObservedObject var musicManager = MusicManager.shared + @ObservedObject private var routeManager = AudioRouteManager.shared + @StateObject private var volumeModel = MediaOutputVolumeViewModel() @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var isActive = true @State private var isExpanded = false + @State private var isVolumeSliderVisible = false @State private var collapseWorkItem: DispatchWorkItem? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelUsesBlur) var enableBlur + @Default(.showMediaOutputControl) var showMediaOutputControl private let collapsedPanelCornerRadius: CGFloat = 28 private let expandedPanelCornerRadius: CGFloat = 52 @@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View { private let expandedAlbumArtCornerRadius: CGFloat = 60 private let expandedContentSpacing: CGFloat = 40 private let collapseTimeout: TimeInterval = 5 + private let collapsedSliderExtraHeight: CGFloat = 72 + private let expandedSliderExtraHeight: CGFloat = 88 private var currentSize: CGSize { - isExpanded ? Self.expandedSize : Self.collapsedSize + let base = isExpanded ? Self.expandedSize : Self.collapsedSize + return CGSize(width: base.width, height: base.height + sliderExtraHeight) } private var panelCornerRadius: CGFloat { @@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View { panelCore .frame(width: currentSize.width, height: currentSize.height) .animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded) + .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider) .onAppear { sliderValue = musicManager.elapsedTime isActive = true logPanelAppearance() - LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false) + LockScreenPanelManager.shared.updatePanelSize( + expanded: isExpanded, + additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider), + animated: false + ) + routeManager.refreshDevices() } .onDisappear { isActive = false cancelCollapseTimer() + isVolumeSliderVisible = false } .onChange(of: isExpanded) { _, expanded in - LockScreenPanelManager.shared.updatePanelSize(expanded: expanded) + LockScreenPanelManager.shared.updatePanelSize( + expanded: expanded, + additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + ) + } + .onChange(of: showMediaOutputControl) { _, enabled in + if !enabled { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + isVolumeSliderVisible = false + } + } + LockScreenPanelManager.shared.updatePanelSize( + expanded: isExpanded, + additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) + ) } } @@ -332,29 +360,45 @@ struct LockScreenMusicPanel: View { private func playbackControls(alignment: Alignment) -> some View { let spacing: CGFloat = isExpanded ? 24 : 20 - return HStack(spacing: spacing) { - // Always show shuffle on lock screen - controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { - musicManager.toggleShuffle() - } - - controlButton(icon: "backward.fill", size: 18) { - musicManager.previousTrack() - } - - playPauseButton - - controlButton(icon: "forward.fill", size: 18) { - musicManager.nextTrack() - } - - // Always show repeat on lock screen - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - musicManager.toggleRepeat() + return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) { + controlsRow(alignment: alignment, spacing: spacing) + + if shouldShowVolumeSlider { + volumeSlider + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) } } .frame(maxWidth: .infinity, alignment: alignment) .padding(.top, isExpanded ? 6 : 2) + .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider) + } + + private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { + HStack(spacing: spacing) { + controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { + musicManager.toggleShuffle() + } + + controlButton(icon: "backward.fill", size: 18) { + musicManager.previousTrack() + } + + playPauseButton + + controlButton(icon: "forward.fill", size: 18) { + musicManager.nextTrack() + } + + if showMediaOutputControl { + mediaOutputControlButton + } else { + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + musicManager.toggleRepeat() + } + } + } + .frame(maxWidth: .infinity, alignment: alignment) } private var playPauseButton: some View { @@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View { .buttonStyle(PlainButtonStyle()) } + private var mediaOutputControlButton: some View { + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? 26 : 18 + + return Button(action: toggleVolumeSlider) { + Image(systemName: mediaOutputIcon) + .font(.system(size: iconSize, weight: .medium)) + .foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) + .frame(width: frameSize, height: frameSize) + .contentShape(Rectangle()) + } + .accessibilityLabel("Media output") + .buttonStyle(PlainButtonStyle()) + } + private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" @@ -399,6 +458,87 @@ struct LockScreenMusicPanel: View { } } + private var mediaOutputIcon: String { + routeManager.activeDevice?.iconName ?? "speaker.wave.2" + } + + private var volumeSlider: some View { + HStack(spacing: 14) { + Image(systemName: volumeIconName) + .font(.system(size: isExpanded ? 16 : 14, weight: .semibold)) + .foregroundColor(.white.opacity(0.8)) + + Slider( + value: Binding( + get: { Double(volumeModel.level) }, + set: { newValue in + registerInteraction() + volumeModel.setVolume(Float(newValue)) + } + ), + in: 0 ... 1 + ) + .tint(sliderColor) + + Text(volumePercentage) + .font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 48, alignment: .trailing) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + } + + private var shouldShowVolumeSlider: Bool { + showMediaOutputControl && isVolumeSliderVisible + } + + private var sliderExtraHeight: CGFloat { + sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) + } + + private var volumeIconName: String { + if volumeModel.isMuted || volumeModel.level <= 0.001 { + return "speaker.slash.fill" + } else if volumeModel.level < 0.33 { + return "speaker.wave.1.fill" + } else if volumeModel.level < 0.66 { + return "speaker.wave.2.fill" + } + return "speaker.wave.3.fill" + } + + private var volumePercentage: String { + "\(Int(round(volumeModel.level * 100)))%" + } + + private func toggleVolumeSlider() { + registerInteraction() + let newState = !isVolumeSliderVisible + if newState { + routeManager.refreshDevices() + } + + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + isVolumeSliderVisible = newState + } + + let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) + LockScreenPanelManager.shared.updatePanelSize( + expanded: isExpanded, + additionalHeight: targetHeight + ) + } + + private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat { + guard visible else { return 0 } + return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight + } + @ViewBuilder private var panelBackground: some View { if enableBlur { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 9aa2bee..07fdb19 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -3,22 +3,25 @@ import SwiftUI struct LockScreenWeatherWidget: View { let snapshot: LockScreenWeatherSnapshot + private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) + var body: some View { - HStack(spacing: 8) { - Image(systemName: snapshot.symbolName) - .font(.system(size: 26, weight: .medium)) - .symbolRenderingMode(.hierarchical) - Text(snapshot.temperatureText) - .font(.system(size: 24, weight: .semibold, design: .rounded)) - .kerning(-0.4) - if let locationName = snapshot.locationName, !locationName.isEmpty { - Text("•") - .font(.system(size: 22, weight: .semibold, design: .rounded)) - Text(locationName) - .font(.system(size: 20, weight: .medium, design: .rounded)) - .foregroundStyle(Color.white.opacity(0.65)) + HStack(alignment: .firstTextBaseline, spacing: 16) { + if let charging = snapshot.charging { + chargingSegment(for: charging) + } + + if let bluetooth = snapshot.bluetooth { + bluetoothSegment(for: bluetooth) + } + + weatherSegment + + if shouldShowLocation { + locationSegment } } + .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -28,20 +31,151 @@ struct LockScreenWeatherWidget: View { .accessibilityLabel(accessibilityLabel) } + private var weatherSegment: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: snapshot.symbolName) + .font(.system(size: 26, weight: .medium)) + .symbolRenderingMode(.hierarchical) + Text(snapshot.temperatureText) + .font(primaryFont) + .kerning(-0.3) + .lineLimit(1) + .minimumScaleFactor(0.9) + .layoutPriority(2) + } + } + + @ViewBuilder + private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { + if let iconName = chargingIconName(for: info) { + HStack(spacing: 4) { + Image(systemName: iconName) + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + + if let text = formattedChargingTime(for: info) { + Text(text) + .font(primaryFont) + .lineLimit(1) + .minimumScaleFactor(0.75) + .layoutPriority(1.2) + } + } + .layoutPriority(1) + } + } + + @ViewBuilder + private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { + HStack(spacing: 4) { + Image(systemName: info.iconName) + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + Text("\(clampedBatteryLevel(info.batteryLevel))%") + .font(primaryFont) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .layoutPriority(1.4) + } + } + + @ViewBuilder + private var locationSegment: some View { + if let locationName = snapshot.locationName { + Text(locationName) + .font(primaryFont) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.75) + .layoutPriority(0.7) + } + } + + private var shouldShowLocation: Bool { + snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) + } + + private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { + let icon = info.iconName + return icon.isEmpty ? nil : icon + } + + private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { + guard let minutes = info.minutesRemaining, minutes > 0 else { + return nil + } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + + if hours > 0 { + return "\(hours)h \(remainingMinutes)m" + } + return "\(remainingMinutes)m" + } + + private func clampedBatteryLevel(_ level: Int) -> Int { + min(max(level, 0), 100) + } + private var accessibilityLabel: String { - if let locationName = snapshot.locationName, !locationName.isEmpty { - return String( - format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), - snapshot.description, - snapshot.temperatureText, - locationName + var components: [String] = [] + + if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty { + components.append( + String( + format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), + snapshot.description, + snapshot.temperatureText, + locationName + ) + ) + } else { + components.append( + String( + format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), + snapshot.description, + snapshot.temperatureText + ) ) } + if let charging = snapshot.charging { + components.append(accessibilityChargingText(for: charging)) + } + + if let bluetooth = snapshot.bluetooth { + components.append(accessibilityBluetoothText(for: bluetooth)) + } + + return components.joined(separator: ". ") + } + + private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String { + if let minutes = charging.minutesRemaining, minutes > 0 { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .full + let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes" + return String( + format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"), + duration + ) + } + + if charging.isPluggedIn && !charging.isCharging { + return NSLocalizedString("Battery fully charged", comment: "Battery is full") + } + + return NSLocalizedString("Battery charging", comment: "Battery charging without estimate") + } + + private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String { return String( - format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), - snapshot.description, - snapshot.temperatureText + format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"), + bluetooth.deviceName, + bluetooth.batteryLevel ) } } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index a18243f..515fc79 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -11,6 +11,7 @@ import Defaults struct MinimalisticMusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID + @Default(.showMediaOutputControl) var showMediaOutputControl var body: some View { VStack(spacing: 0) { @@ -182,8 +183,12 @@ struct MinimalisticMusicPlayerView: View { } if Defaults[.showShuffleAndRepeat] { - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - Task { await musicManager.toggleRepeat() } + if showMediaOutputControl { + MinimalisticMediaOutputButton() + } else { + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + Task { await musicManager.toggleRepeat() } + } } } } @@ -216,7 +221,61 @@ struct MinimalisticMusicPlayerView: View { action: action ) } - + private struct MinimalisticMediaOutputButton: View { + @ObservedObject private var routeManager = AudioRouteManager.shared + @StateObject private var volumeModel = MediaOutputVolumeViewModel() + @EnvironmentObject private var vm: DynamicIslandViewModel + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + + var body: some View { + MinimalisticSquircircleButton( + icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2", + fontSize: 18, + fontWeight: .medium, + frameSize: CGSize(width: 40, height: 40), + cornerRadius: 16, + foregroundColor: .white.opacity(0.85) + ) { + isPopoverPresented.toggle() + if isPopoverPresented { + routeManager.refreshDevices() + } + } + .accessibilityLabel("Media output") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + MediaOutputSelectorPopover( + routeManager: routeManager, + volumeModel: volumeModel, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updateActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updateActivity() + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { + isHoveringPopover = false + } + updateActivity() + } + .onAppear { + routeManager.refreshDevices() + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private func updateActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } + } + private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index f999438..78fd7c8 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -116,6 +116,7 @@ struct MusicControlsView: View { @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast let showShuffleAndRepeat: Bool + @Default(.showMediaOutputControl) private var showMediaOutputControl var body: some View { VStack(alignment: .leading) { @@ -192,8 +193,12 @@ struct MusicControlsView: View { MusicManager.shared.nextTrack() } if showShuffleAndRepeat { - HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { - MusicManager.shared.toggleRepeat() + if showMediaOutputControl { + MediaOutputPickerButton() + } else { + HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { + MusicManager.shared.toggleRepeat() + } } } } @@ -382,6 +387,239 @@ struct CustomSlider: View { } } +private struct MediaOutputPickerButton: View { + @ObservedObject private var routeManager = AudioRouteManager.shared + @StateObject private var volumeModel = MediaOutputVolumeViewModel() + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + @EnvironmentObject private var vm: DynamicIslandViewModel + + var body: some View { + HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + routeManager.refreshDevices() + } + } + .accessibilityLabel("Media output") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + MediaOutputSelectorPopover( + routeManager: routeManager, + volumeModel: volumeModel, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() + } + } + .onAppear { + routeManager.refreshDevices() + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { + isHoveringPopover = false + } + updatePopoverActivity() + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private var buttonIcon: String { + routeManager.activeDevice?.iconName ?? "speaker.wave.2" + } + + private func updatePopoverActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } +} + +struct MediaOutputSelectorPopover: View { + @ObservedObject var routeManager: AudioRouteManager + @ObservedObject var volumeModel: MediaOutputVolumeViewModel + var onHoverChanged: (Bool) -> Void + var dismiss: () -> Void + + var body: some View { + VStack(spacing: 16) { + volumeSection + Divider() + devicesSection + } + .frame(width: 240) + .padding(16) + .onHover { hovering in + onHoverChanged(hovering) + } + .onDisappear { + onHoverChanged(false) + } + } + + private var volumeSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Button { + volumeModel.toggleMute() + } label: { + Image(systemName: volumeIconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(Color.secondary.opacity(0.18)) + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Slider( + value: Binding( + get: { Double(volumeModel.level) }, + set: { newValue in + volumeModel.setVolume(Float(newValue)) + } + ), + in: 0 ... 1 + ) + .tint(.accentColor) + } + + HStack { + Text("Output volume") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(volumePercentage) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var devicesSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Output devices") + .font(.caption) + .foregroundColor(.secondary) + + if routeManager.devices.isEmpty { + Text("No audio outputs available") + .font(.callout) + .foregroundColor(.secondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(routeManager.devices) { device in + Button { + routeManager.select(device: device) + dismiss() + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: 14, weight: .medium)) + Text(device.name) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + if device.id == routeManager.activeDeviceID { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear) + ) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + } + } + } + + private var volumeIconName: String { + if volumeModel.isMuted || volumeModel.level <= 0.001 { + return "speaker.slash.fill" + } else if volumeModel.level < 0.33 { + return "speaker.wave.1.fill" + } else if volumeModel.level < 0.66 { + return "speaker.wave.2.fill" + } + return "speaker.wave.3.fill" + } + + private var volumePercentage: String { + "\(Int(round(volumeModel.level * 100)))%" + } +} + +final class MediaOutputVolumeViewModel: ObservableObject { + @Published var level: Float + @Published var isMuted: Bool + + private let controller: SystemVolumeController + private var cancellables: Set = [] + + init(controller: SystemVolumeController = .shared) { + self.controller = controller + controller.start() + level = controller.currentVolume + isMuted = controller.isMuted + + NotificationCenter.default.publisher(for: .systemVolumeDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + guard let self, + let value = notification.userInfo?["value"] as? Float, + let muted = notification.userInfo?["muted"] as? Bool else { return } + self.level = value + self.isMuted = muted + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .systemAudioRouteDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.syncFromController() + } + .store(in: &cancellables) + } + + func setVolume(_ value: Float) { + level = value + if value > 0 { + isMuted = false + } + controller.setVolume(value) + } + + func toggleMute() { + isMuted.toggle() + controller.toggleMute() + } + + private func syncFromController() { + level = controller.currentVolume + isMuted = controller.isMuted + } +} + #Preview { NotchHomeView( albumArtNamespace: Namespace().wrappedValue diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index dc29bb0..be3e6c6 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -178,6 +178,10 @@ struct GeneralSettings: View { @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder @Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur + @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget + @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation + @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging + @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth var body: some View { Form { @@ -324,6 +328,11 @@ struct GeneralSettings: View { Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) + if enableLockScreenWeatherWidget { + Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation) + Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) + Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) + } Button("Copy Latest Crash Report") { copyLatestCrashReport() @@ -666,6 +675,9 @@ struct HUD: View { Section { Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections) + Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator) + Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText) + Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) } header: { Text("Bluetooth Audio Devices") } footer: { @@ -675,27 +687,30 @@ struct HUD: View { } Section { + let colorCodingDisabled = progressBarStyle == .segmented Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay) - .disabled(progressBarStyle != .gradient) + .disabled(colorCodingDisabled) Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay) - .disabled(progressBarStyle != .gradient) - - if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) { + .disabled(colorCodingDisabled) + + if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) { Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient) } + + Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages) } header: { Text("Color-Coded Progress Bars") } footer: { - if progressBarStyle != .gradient { - Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.") + if progressBarStyle == .segmented { + 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) } else if Defaults[.useSmoothColorGradient] { - Text("Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)") + Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.") .foregroundStyle(.secondary) .font(.caption) } else { - Text("Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)") + Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).") .foregroundStyle(.secondary) .font(.caption) } @@ -744,6 +759,7 @@ struct Media: View { @Default(.enableSneakPeek) private var enableSneakPeek @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat var body: some View { Form { @@ -784,6 +800,10 @@ struct Media: View { customBadge(text: "Beta") } } + Defaults.Toggle(key: .showMediaOutputControl) { + Text("Replace repeat button with media output control") + } + .disabled(!showShuffleAndRepeat) } header: { Text("Media controls") } diff --git a/DynamicIsland/managers/AudioRouteManager.swift b/DynamicIsland/managers/AudioRouteManager.swift new file mode 100644 index 0000000..a481a2b --- /dev/null +++ b/DynamicIsland/managers/AudioRouteManager.swift @@ -0,0 +1,245 @@ +import Combine +import CoreAudio +import Foundation + +struct AudioOutputDevice: Identifiable, Equatable { + let id: AudioDeviceID + let name: String + let transportType: UInt32 + + var iconName: String { + let normalizedName = name.lowercased() + + if normalizedName.contains("airpods") { + return "airpodspro" + } + + if normalizedName.contains("macbook") { + return "laptopcomputer" + } + + if normalizedName.contains("headphone") || normalizedName.contains("headset") { + return "headphones" + } + + if normalizedName.contains("beats") { + return "headphones" + } + + if normalizedName.contains("homepod") { + return "hifispeaker.2" + } + + switch transportType { + case kAudioDeviceTransportTypeBluetooth: + if normalizedName.contains("speaker") { + return "speaker.wave.2" + } + return "headphones" + case kAudioDeviceTransportTypeAirPlay: + return "airplayaudio" + case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI: + return "tv" + case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeFireWire: + return "hifispeaker.2" + case kAudioDeviceTransportTypePCI, kAudioDeviceTransportTypeVirtual: + return "speaker.wave.2" + case kAudioDeviceTransportTypeBuiltIn: + return normalizedName.contains("display") ? "tv" : "speaker.wave.2" + default: + return "speaker.wave.2" + } + } +} + +final class AudioRouteManager: ObservableObject { + static let shared = AudioRouteManager() + + @Published private(set) var devices: [AudioOutputDevice] = [] + @Published private(set) var activeDeviceID: AudioDeviceID = 0 + + private let queue = DispatchQueue(label: "com.dynamicisland.audio-route", qos: .userInitiated) + + private init() { + refreshDevices() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleRouteChange), + name: .systemAudioRouteDidChange, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + var activeDevice: AudioOutputDevice? { + devices.first { $0.id == activeDeviceID } + } + + func refreshDevices() { + queue.async { [weak self] in + guard let self else { return } + let defaultID = self.fetchDefaultOutputDevice() + let deviceInfos = self.fetchOutputDeviceIDs().compactMap(self.makeDeviceInfo) + let sortedDevices = deviceInfos.sorted { lhs, rhs in + if lhs.id == defaultID { return true } + if rhs.id == defaultID { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + DispatchQueue.main.async { + self.activeDeviceID = defaultID + self.devices = sortedDevices + } + } + } + + func select(device: AudioOutputDevice) { + queue.async { [weak self] in + guard let self else { return } + self.setDefaultOutputDevice(device.id) + } + } + + // MARK: - Private + + @objc private func handleRouteChange() { + refreshDevices() + } + + private func fetchOutputDeviceIDs() -> [AudioDeviceID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + guard AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &dataSize + ) == noErr else { + return [] + } + + let deviceCount = Int(dataSize) / MemoryLayout.size + var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &dataSize, + &deviceIDs + ) + if status != noErr { + return [] + } + return deviceIDs + } + + private func makeDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? { + guard deviceHasOutputChannels(deviceID) else { return nil } + let name = deviceName(for: deviceID) ?? "Unknown Device" + let transport = transportType(for: deviceID) + return AudioOutputDevice(id: deviceID, name: name, transportType: transport) + } + + private func deviceHasOutputChannels(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr else { + return false + } + + let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: MemoryLayout.alignment) + defer { buffer.deallocate() } + + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, buffer) == noErr else { + return false + } + + let audioBufferListPointer = buffer.assumingMemoryBound(to: AudioBufferList.self) + let audioBuffers = UnsafeMutableAudioBufferListPointer(audioBufferListPointer) + let channelCount = audioBuffers.reduce(0) { $0 + Int($1.mNumberChannels) } + return channelCount > 0 + } + + private func deviceName(for deviceID: AudioDeviceID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var name: CFString = "" as CFString + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &name) + guard status == noErr else { return nil } + return name as String + } + + private func transportType(for deviceID: AudioDeviceID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var type: UInt32 = kAudioDeviceTransportTypeUnknown + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &type) + return status == noErr ? type : kAudioDeviceTransportTypeUnknown + } + + private func fetchDefaultOutputDevice() -> AudioDeviceID { + var deviceID = AudioDeviceID() + var dataSize = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &dataSize, + &deviceID + ) + return status == noErr ? deviceID : 0 + } + + private func setDefaultOutputDevice(_ deviceID: AudioDeviceID) { + var target = deviceID + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + UInt32(MemoryLayout.size), + &target + ) + if status == noErr { + DispatchQueue.main.async { [weak self] in + self?.activeDeviceID = deviceID + } + refreshDevices() + } + } +} diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 857d5b7..5ee4e7e 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -12,6 +12,7 @@ import AppKit import Defaults import SwiftUI import IOBluetooth +import IOKit /// Manages detection and monitoring of Bluetooth audio device connections class BluetoothAudioManager: ObservableObject { @@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject { private var cancellables = Set() private let coordinator = DynamicIslandViewCoordinator.shared private var pollingTimer: Timer? + private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" + + @Published private(set) var batteryStatus: [String: String] = [:] + + private var batteryStatusByAddress: [String: Int] = [:] + private var batteryStatusByName: [String: Int] = [:] + private var missingBatteryLog: Set = [] + private var lastBatteryStatusUpdate: Date? + private let batteryStatusUpdateInterval: TimeInterval = 20 // MARK: - Initialization private init() { @@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject { // Update connection state isBluetoothAudioConnected = !connectedDevices.isEmpty + refreshBatteryLevelsForConnectedDevices() + if let lastDevice = connectedDevices.last { lastConnectedDevice = lastDevice print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)") @@ -201,9 +213,15 @@ class BluetoothAudioManager: ObservableObject { connectedDevices.append(audioDevice) lastConnectedDevice = audioDevice isBluetoothAudioConnected = true + + refreshBatteryLevelsForConnectedDevices() // Show HUD for new connection - showDeviceConnectedHUD(audioDevice) + if let refreshedDevice = connectedDevices.last { + showDeviceConnectedHUD(refreshedDevice) + } else { + showDeviceConnectedHUD(audioDevice) + } } } } @@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject { } isBluetoothAudioConnected = !connectedDevices.isEmpty + + refreshBatteryLevelsForConnectedDevices() } /// Handles Bluetooth device connection event (legacy - kept for compatibility) @@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject { /// Extracts battery level from Bluetooth device private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? { - // Try to read battery level from device - // Note: This may require additional permissions or may not be available for all devices - - // Placeholder: Return nil if not available - // TODO: Implement actual battery level reading if device supports it + updateBatteryStatuses() + + if let level = batteryLevelFromRegistry(forAddress: device.addressString) { + clearMissingBatteryInfo(for: device) + return level + } + + if let name = device.name, let level = batteryLevelFromRegistry(forName: name) { + clearMissingBatteryInfo(for: device) + return level + } + + if let level = batteryLevelFromDefaults(forAddress: device.addressString) { + clearMissingBatteryInfo(for: device) + return level + } + + if let name = device.name, let level = batteryLevelFromDefaults(forName: name) { + clearMissingBatteryInfo(for: device) + return level + } + + logMissingBatteryInfo(for: device) return nil } @@ -372,6 +410,590 @@ class BluetoothAudioManager: ObservableObject { default: return .generic } } + + private func refreshBatteryLevelsForConnectedDevices() { + updateBatteryStatuses(force: true) + + var updatedDevices: [BluetoothAudioDevice] = [] + for device in connectedDevices { + let refreshedLevel = + batteryLevelFromRegistry(forAddress: device.address) + ?? batteryLevelFromRegistry(forName: device.name) + ?? batteryLevelFromDefaults(forAddress: device.address) + ?? batteryLevelFromDefaults(forName: device.name) + ?? device.batteryLevel + let updatedDevice = device.withBatteryLevel(refreshedLevel) + updatedDevices.append(updatedDevice) + + if let refreshedLevel { + clearMissingBatteryInfo(forName: device.name, address: device.address) + } else { + logMissingBatteryInfo(forName: device.name, address: device.address) + } + } + + connectedDevices = updatedDevices + if let last = connectedDevices.last { + lastConnectedDevice = last + } + } + + private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { + guard let address, !address.isEmpty else { return nil } + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } + guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil } + + let normalizedTarget = normalizeBluetoothIdentifier(address) + var bestMatch: Int? + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) { + if let level = extractBatteryPercentage(from: payload) { + let clamped = clampBatteryPercentage(level) + bestMatch = max(bestMatch ?? clamped, clamped) + } + } + } + + return bestMatch + } + + private func batteryLevelFromDefaults(forName name: String) -> Int? { + guard !name.isEmpty else { return nil } + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } + guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil } + + var bestMatch: Int? + + for value in deviceCache.values { + guard let payload = value as? [String: Any] else { continue } + let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) + if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame { + if let level = extractBatteryPercentage(from: payload) { + let clamped = clampBatteryPercentage(level) + bestMatch = max(bestMatch ?? clamped, clamped) + } + } + } + + return bestMatch + } + + private func batteryLevelFromRegistry(forName name: String) -> Int? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = normalizeProductName(trimmed) + guard !normalized.isEmpty else { return nil } + if let value = batteryStatusByName[normalized] { + return clampBatteryPercentage(value) + } + return nil + } + + private func updateBatteryStatuses(force: Bool = false) { + let now = Date() + if !force, let lastBatteryStatusUpdate, + now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval { + return + } + + var combinedAddressPercentages: [String: Int] = [:] + var combinedNamePercentages: [String: Int] = [:] + + let registry = collectRegistryBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names) + + let defaults = collectDefaultsBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names) + + let profiler = collectSystemProfilerBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names) + + var statuses: [String: String] = [:] + for (key, value) in combinedAddressPercentages { + statuses[key] = String(clampBatteryPercentage(value)) + } + + let applyUpdates = { + self.batteryStatus = statuses + self.batteryStatusByAddress = combinedAddressPercentages + self.batteryStatusByName = combinedNamePercentages + self.lastBatteryStatusUpdate = now + } + + if Thread.isMainThread { + applyUpdates() + } else { + DispatchQueue.main.sync(execute: applyUpdates) + } + } + + private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) { + guard !source.isEmpty else { return } + for (key, value) in source { + guard !key.isEmpty else { continue } + if let existing = target[key] { + target[key] = max(existing, value) + } else { + target[key] = value + } + } + } + + private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + var iterator = io_iterator_t() + let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService") + + let servicePort: mach_port_t + if #available(macOS 12.0, *) { + servicePort = kIOMainPortDefault + } else { + servicePort = kIOMasterPortDefault + } + + let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator) + + if kernResult == KERN_SUCCESS { + var entry: io_object_t = IOIteratorNext(iterator) + while entry != 0 { + if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int { + let normalizedPercent = clampBatteryPercentage(percent) + + let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"] + for key in identifierKeys { + if let identifier = stringValue(forKey: key, entry: entry) { + let normalizedIdentifier = normalizeBluetoothIdentifier(identifier) + if !normalizedIdentifier.isEmpty { + if let existing = addressPercentages[normalizedIdentifier] { + addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent) + } else { + addressPercentages[normalizedIdentifier] = normalizedPercent + } + } + } + } + + let nameKeys = [ + "Product", + "ProductName", + "DeviceName", + "Name", + "USB Product Name", + "Bluetooth Product Name" + ] + + for key in nameKeys { + if let product = stringValue(forKey: key, entry: entry) { + let normalizedName = normalizeProductName(product) + if !normalizedName.isEmpty { + if let existing = namePercentages[normalizedName] { + namePercentages[normalizedName] = max(existing, normalizedPercent) + } else { + namePercentages[normalizedName] = normalizedPercent + } + } + } + } + } + + IOObjectRelease(entry) + entry = IOIteratorNext(iterator) + } + } + + IOObjectRelease(iterator) + + return (addressPercentages, namePercentages) + } + + private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { + return ([:], [:]) + } + + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + guard let level = extractBatteryPercentage(from: payload) else { continue } + let clamped = clampBatteryPercentage(level) + + let normalizedKey = normalizeBluetoothIdentifier(key) + if !normalizedKey.isEmpty { + addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped) + } + + for identifier in identifiersFromDeviceCachePayload(payload) { + addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped) + } + + if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) { + let normalizedName = normalizeProductName(name) + if !normalizedName.isEmpty { + namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped) + } + } + } + + return (addressPercentages, namePercentages) + } + + private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + guard let root = systemProfilerBluetoothDictionary() else { + return ([:], [:]) + } + + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + if let connectedList = root["device_connected"] as? [[String: [String: Any]]] { + for deviceGroup in connectedList { + for (rawName, payload) in deviceGroup { + guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue } + let clamped = clampBatteryPercentage(percent) + + let normalizedName = normalizeProductName(rawName) + if !normalizedName.isEmpty { + namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped) + } + + for address in profilerAddressCandidates(from: payload) { + let normalizedAddress = normalizeBluetoothIdentifier(address) + if !normalizedAddress.isEmpty { + addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped) + } + } + } + } + } + + return (addressPercentages, namePercentages) + } + + private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] { + var identifiers: Set = [] + let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"] + + for key in candidateKeys { + if let value = payload[key] as? String { + let normalized = normalizeBluetoothIdentifier(value) + if !normalized.isEmpty { + identifiers.insert(normalized) + } + } else if let data = payload[key] as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeBluetoothIdentifier(ascii) + if !normalized.isEmpty { + identifiers.insert(normalized) + } + } + } + + return Array(identifiers) + } + + private func systemProfilerBluetoothDictionary() -> [String: Any]? { + let process = Process() + process.launchPath = "/usr/sbin/system_profiler" + process.arguments = ["SPBluetoothDataType", "-json"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + let errorPipe = Pipe() + process.standardError = errorPipe + + do { + try process.run() + } catch { + return nil + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return nil + } + guard !data.isEmpty else { return nil } + + guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]], + let root = entries.first else { + return nil + } + + return root + } + + private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? { + let preferredKeys = [ + "device_batteryLevelCase", + "device_batteryLevelLeft", + "device_batteryLevelRight", + "device_batteryLevelMain", + "device_batteryLevel", + "device_batteryLevelCombined", + "device_batteryPercentCombined", + "Left Battery Level", + "Right Battery Level", + "Battery Level", + "BatteryPercent" + ] + + var values: [Int] = [] + + for key in preferredKeys { + if let raw = payload[key], let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + + if values.isEmpty { + for (key, raw) in payload where key.lowercased().contains("battery") { + if let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + } + + let validValues = values.filter { $0 >= 0 } + return validValues.max() + } + + private func profilerAddressCandidates(from payload: [String: Any]) -> [String] { + var addresses: Set = [] + let keys = [ + "device_address", + "device_mac_address", + "device_serial_num", + "device_serialNumber", + "device_serial_number" + ] + + for key in keys { + if let value = payload[key] as? String, !value.isEmpty { + addresses.insert(value) + } else if let data = payload[key] as? Data, + let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty { + addresses.insert(ascii) + } + } + + return Array(addresses) + } + + private func batteryLevelFromRegistry(forAddress address: String?) -> Int? { + guard let address, !address.isEmpty else { return nil } + let normalized = normalizeBluetoothIdentifier(address) + if let value = batteryStatusByAddress[normalized] { + return clampBatteryPercentage(value) + } + if let storedValue = batteryStatus[normalized], let value = Int(storedValue) { + return clampBatteryPercentage(value) + } + return nil + } + + private func extractBatteryPercentage(from payload: [String: Any]) -> Int? { + let keys = [ + "BatteryPercent", + "BatteryPercentCase", + "BatteryPercentLeft", + "BatteryPercentRight", + "BatteryPercentSingle", + "BatteryPercentCombined", + "BatteryPercentMain", + "device_batteryLevelLeft", + "device_batteryLevelRight", + "device_batteryLevelMain", + "Left Battery Level", + "Right Battery Level" + ] + + var values: [Int] = [] + + for key in keys { + guard let raw = payload[key] else { continue } + if let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + + if values.isEmpty, + let services = payload["Services"] as? [[String: Any]] { + for service in services { + if let serviceValues = service["BatteryPercentages"] as? [String: Any] { + for value in serviceValues.values { + if let converted = convertToBatteryPercentage(value) { + values.append(converted) + } + } + } + } + } + + return values.max() + } + + private func convertToBatteryPercentage(_ value: Any) -> Int? { + if let number = value as? Int { + if number == 1 { + return 100 + } + return number + } + if let number = value as? Double { + if number <= 1.0 { + return Int(number * 100) + } + return Int(number) + } + if let string = value as? String { + let trimmed = string.replacingOccurrences(of: "%", with: "") + if let doubleValue = Double(trimmed) { + if doubleValue <= 1.0 { + return Int(doubleValue * 100) + } + return Int(doubleValue) + } + } + + return nil + } + + private func clampBatteryPercentage(_ value: Int) -> Int { + min(max(value, 0), 100) + } + + private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool { + if normalizeBluetoothIdentifier(key) == normalizedTarget { + return true + } + + let candidateFields: [String?] = [ + payload["DeviceAddress"] as? String, + payload["Address"] as? String, + payload["BD_ADDR"] as? String, + payload["SerialNumber"] as? String + ] + + for field in candidateFields { + if let field, normalizeBluetoothIdentifier(field) == normalizedTarget { + return true + } + } + + if let deviceAddressData = payload["DeviceAddress"] as? Data, + let ascii = String(data: deviceAddressData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + if let addressData = payload["BD_ADDR"] as? Data, + let ascii = String(data: addressData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + if let serialData = payload["SerialNumber"] as? Data, + let ascii = String(data: serialData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + return false + } + + private func logMissingBatteryInfo(for device: IOBluetoothDevice) { + let name = device.name ?? "" + let address = device.addressString ?? "" + logMissingBatteryInfo(forName: name, address: address) + } + + private func clearMissingBatteryInfo(for device: IOBluetoothDevice) { + let name = device.name ?? "" + let address = device.addressString ?? "" + clearMissingBatteryInfo(forName: name, address: address) + } + + private func logMissingBatteryInfo(forName name: String, address: String) { + let key = missingBatteryKey(name: name, address: address) + guard !missingBatteryLog.contains(key) else { return } + missingBatteryLog.insert(key) + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + + let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName + let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame + let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress + print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))") + } + + private func clearMissingBatteryInfo(forName name: String, address: String) { + let key = missingBatteryKey(name: name, address: address) + missingBatteryLog.remove(key) + } + + private func missingBatteryKey(name: String, address: String) -> String { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + + let normalizedName = normalizeProductName(trimmedName) + + let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame + let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress) + + if normalizedName.isEmpty && normalizedAddress.isEmpty { + return "unknown" + } + + return normalizedName + "#" + normalizedAddress + } + + private func stringValue(forKey key: String, entry: io_object_t) -> String? { + guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else { + return nil + } + + let value = unmanaged.takeRetainedValue() + + if let string = value as? String, !string.isEmpty { + return string + } + + if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty { + return ascii + } + + return nil + } + + private func normalizeBluetoothIdentifier(_ value: String) -> String { + return value + .lowercased() + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + } + + private func normalizeProductName(_ name: String) -> String { + let components = name + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + return components.joined() + } // MARK: - HUD Display @@ -382,11 +1004,19 @@ class BluetoothAudioManager: ObservableObject { print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") // Convert battery percentage to 0.0-1.0 value - let batteryValue: CGFloat = if let battery = device.batteryLevel { - CGFloat(battery) / 100.0 + let reportedBattery = device.batteryLevel + ?? batteryLevelFromRegistry(forAddress: device.address) + ?? batteryLevelFromRegistry(forName: device.name) + ?? batteryLevelFromDefaults(forAddress: device.address) + ?? batteryLevelFromDefaults(forName: device.name) + + let batteryValue: CGFloat = if let battery = reportedBattery { + CGFloat(clampBatteryPercentage(battery)) / 100.0 } else { 0.0 // 0 means battery info not available } + + HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5) // Show HUD via coordinator coordinator.toggleSneakPeek( @@ -411,16 +1041,47 @@ class BluetoothAudioManager: ObservableObject { observers.removeAll() cancellables.removeAll() } + + @MainActor + func refreshConnectedDeviceBatteries() { + refreshBatteryLevelsForConnectedDevices() + } } // MARK: - Models struct BluetoothAudioDevice: Identifiable { - let id = UUID() + let id: UUID let name: String let address: String let batteryLevel: Int? // 0-100, nil if not available let deviceType: BluetoothAudioDeviceType + + init( + id: UUID = UUID(), + name: String, + address: String, + batteryLevel: Int?, + deviceType: BluetoothAudioDeviceType + ) { + self.id = id + self.name = name + self.address = address + self.batteryLevel = batteryLevel + self.deviceType = deviceType + } +} + +extension BluetoothAudioDevice { + func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice { + BluetoothAudioDevice( + id: id, + name: name, + address: address, + batteryLevel: batteryLevel, + deviceType: deviceType + ) + } } enum BluetoothAudioDeviceType { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index ad54397..1da67de 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -9,6 +9,7 @@ import SwiftUI import AppKit import SkyLightWindow import Defaults +import QuartzCore @MainActor class LockScreenPanelManager { @@ -89,18 +90,24 @@ class LockScreenPanelManager { print("[\(timestamp())] LockScreenPanelManager: panel visible") } - func updatePanelSize(expanded: Bool, animated: Bool = true) { + func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) { guard let window = panelWindow, let baseFrame = collapsedFrame else { return } - let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize - let originX = baseFrame.midX - (targetSize.width / 2) + let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize + let targetWidth = baseSize.width + let targetHeight = baseSize.height + additionalHeight + let originX = baseFrame.midX - (targetWidth / 2) let originY = baseFrame.origin.y - let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height) + let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight) if animated { - window.animator().setFrame(targetFrame, display: true) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.45 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(targetFrame, display: true) + } } else { window.setFrame(targetFrame, display: true) } diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index cb0c272..ae3483d 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -1,6 +1,7 @@ import Foundation import Defaults import CoreLocation +import Combine @MainActor final class LockScreenWeatherManager: ObservableObject { @@ -11,8 +12,12 @@ final class LockScreenWeatherManager: ObservableObject { private let provider = LockScreenWeatherProvider() private let locationProvider = LockScreenWeatherLocationProvider() private var lastFetchDate: Date? + private var latestWeatherPayload: LockScreenWeatherSnapshot? + private var cancellables = Set() - private init() {} + private init() { + observeAccessoryChanges() + } func prepareLocationAccess() { locationProvider.prepareAuthorization() @@ -28,9 +33,15 @@ final class LockScreenWeatherManager: ObservableObject { Task { @MainActor [weak self] in guard let self else { return } - await self.refresh(force: self.snapshot == nil) - if let snapshot = self.snapshot { - LockScreenWeatherPanelManager.shared.show(with: snapshot) + let snapshot = await self.refresh(force: self.snapshot == nil) + + guard LockScreenManager.shared.currentLockStatus else { + LockScreenWeatherPanelManager.shared.hide() + return + } + + if let snapshot { + self.deliver(snapshot, forceShow: true) } else { LockScreenWeatherPanelManager.shared.hide() } @@ -41,32 +52,222 @@ final class LockScreenWeatherManager: ObservableObject { LockScreenWeatherPanelManager.shared.hide() } - func refresh(force: Bool = false) async { + @discardableResult + func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? { let interval = Defaults[.lockScreenWeatherRefreshInterval] if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval { - if let snapshot = snapshot { - LockScreenWeatherPanelManager.shared.update(with: snapshot) + if let payload = latestWeatherPayload { + if Defaults[.lockScreenWeatherShowsBluetooth] { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + let snapshot = makeSnapshot(from: payload) + self.snapshot = snapshot + deliver(snapshot, forceShow: false) + return snapshot + } else if let snapshot = snapshot { + deliver(snapshot, forceShow: false) + return snapshot } - return + return snapshot } do { let location = await locationProvider.currentLocation() - let snapshot = try await provider.fetchSnapshot(location: location) + let payload = try await provider.fetchSnapshot(location: location) + latestWeatherPayload = payload + if Defaults[.lockScreenWeatherShowsBluetooth] { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + let snapshot = makeSnapshot(from: payload) self.snapshot = snapshot lastFetchDate = Date() - LockScreenWeatherPanelManager.shared.update(with: snapshot) + deliver(snapshot, forceShow: false) + return snapshot } catch { NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") + + let fallback = LockScreenWeatherSnapshot( + temperatureText: snapshot?.temperatureText ?? "--", + symbolName: snapshot?.symbolName ?? "cloud.fill", + description: snapshot?.description ?? "", + locationName: snapshot?.locationName, + charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, + bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, + showsLocation: snapshot?.showsLocation ?? false + ) + + self.snapshot = fallback + deliver(fallback, forceShow: false) + return fallback } } + + private func observeAccessoryChanges() { + let bluetoothManager = BluetoothAudioManager.shared + + bluetoothManager.$connectedDevices + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + bluetoothManager.$lastConnectedDevice + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + let battery = BatteryStatusViewModel.shared + let batteryPublishers: [AnyPublisher] = [ + battery.$isCharging.map { _ in () }.eraseToAnyPublisher(), + battery.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(), + battery.$timeToFullCharge.map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(batteryPublishers) + .debounce(for: .milliseconds(150), scheduler: RunLoop.main) + .sink { [weak self] in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + let defaultsPublishers: [AnyPublisher] = [ + Defaults.publisher(.lockScreenWeatherShowsLocation, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsCharging, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) + .map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(defaultsPublishers) + .sink { [weak self] in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: true) + } + .store(in: &cancellables) + } + + private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) { + guard let payload = latestWeatherPayload else { return } + + if triggerBluetoothRefresh { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + + let snapshot = makeSnapshot(from: payload) + self.snapshot = snapshot + deliver(snapshot, forceShow: false) + } + + private func deliver(_ snapshot: LockScreenWeatherSnapshot, forceShow: Bool) { + guard LockScreenManager.shared.currentLockStatus else { return } + + if forceShow { + LockScreenWeatherPanelManager.shared.show(with: snapshot) + } else { + LockScreenWeatherPanelManager.shared.update(with: snapshot) + } + } + + private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot { + let locationName = payload.locationName + let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) + let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil + let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil + + return LockScreenWeatherSnapshot( + temperatureText: payload.temperatureText, + symbolName: payload.symbolName, + description: payload.description, + locationName: locationName, + charging: chargingInfo, + bluetooth: bluetoothInfo, + showsLocation: shouldShowLocation + ) + } + + private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? { + let battery = BatteryStatusViewModel.shared + let macStatus = MacBatteryManager.shared.currentStatus() + + let isPluggedIn = battery.isPluggedIn || battery.isCharging + let isCharging = macStatus.isCharging || battery.isCharging + + guard isPluggedIn || isCharging else { + return nil + } + + let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil) + let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil + + return LockScreenWeatherSnapshot.ChargingInfo( + minutesRemaining: remaining, + isCharging: isCharging, + isPluggedIn: isPluggedIn + ) + } + + private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? { + let manager = BluetoothAudioManager.shared + + guard manager.isBluetoothAudioConnected else { + return nil + } + + let device = manager.connectedDevices.last ?? manager.lastConnectedDevice + + guard let device else { + return nil + } + + guard let batteryLevel = device.batteryLevel else { + return nil + } + + return LockScreenWeatherSnapshot.BluetoothInfo( + deviceName: device.name, + batteryLevel: clampBluetoothBatteryLevel(batteryLevel), + iconName: device.deviceType.sfSymbol + ) + } + + private func clampBluetoothBatteryLevel(_ level: Int) -> Int { + min(max(level, 0), 100) + } } struct LockScreenWeatherSnapshot: Equatable { + struct ChargingInfo: Equatable { + let minutesRemaining: Int? + let isCharging: Bool + let isPluggedIn: Bool + + var iconName: String { + if isCharging { + return "bolt.fill" + } + if isPluggedIn { + return "powerplug.portrait.fill" + } + return "" + } + } + + struct BluetoothInfo: Equatable { + let deviceName: String + let batteryLevel: Int + let iconName: String + } + let temperatureText: String let symbolName: String let description: String let locationName: String? + let charging: ChargingInfo? + let bluetooth: BluetoothInfo? + let showsLocation: Bool } private actor LockScreenWeatherProvider { @@ -122,7 +323,10 @@ private actor LockScreenWeatherProvider { temperatureText: temperatureText, symbolName: symbol, description: description, - locationName: locationName + locationName: locationName, + charging: nil, + bluetooth: nil, + showsLocation: true ) } } diff --git a/DynamicIsland/managers/MacBatteryManager.swift b/DynamicIsland/managers/MacBatteryManager.swift new file mode 100644 index 0000000..fe8ee69 --- /dev/null +++ b/DynamicIsland/managers/MacBatteryManager.swift @@ -0,0 +1,65 @@ +import Foundation +import IOKit.ps + +/// Lightweight helper for querying macOS battery charging status and ETA. +final class MacBatteryManager { + static let shared = MacBatteryManager() + + private init() {} + + struct BatteryStatus { + let timeRemainingMinutes: Int? + let isCharging: Bool + let percentage: Int? + } + + func currentStatus() -> BatteryStatus { + guard let sourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sourcesList = IOPSCopyPowerSourcesList(sourcesInfo)?.takeRetainedValue() as? [CFTypeRef] else { + return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil) + } + + for source in sourcesList { + guard let description = IOPSGetPowerSourceDescription(sourcesInfo, source)?.takeUnretainedValue() as? [String: Any], + let type = description[kIOPSTypeKey] as? String, + type == kIOPSInternalBatteryType else { + continue + } + + let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false + let timeRemaining = description[kIOPSTimeToFullChargeKey] as? Int + let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int + let maxCapacity = description[kIOPSMaxCapacityKey] as? Int + + let percentage: Int? + if let current = currentCapacity, let max = maxCapacity, max > 0 { + percentage = (current * 100) / max + } else { + percentage = nil + } + + return BatteryStatus( + timeRemainingMinutes: timeRemaining, + isCharging: isCharging, + percentage: percentage + ) + } + + return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil) + } + + func formattedTimeToFullCharge() -> String? { + let status = currentStatus() + guard status.isCharging, let minutes = status.timeRemainingMinutes, minutes > 0 else { + return nil + } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + + if hours > 0 { + return "\(hours)h \(remainingMinutes)m" + } + return "\(remainingMinutes)m" + } +} diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift index 8e1ca19..44441ec 100644 --- a/DynamicIsland/managers/SystemChangesObserver.swift +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -93,6 +93,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { // MARK: - HUD Dispatch private func sendVolumeNotification(value: Float) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { + return + } coordinator?.toggleSneakPeek( status: true, type: .volume, diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index a5add30..9e41d55 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -9,6 +9,38 @@ extension Notification.Name { static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange") } +final class HUDSuppressionCoordinator { + static let shared = HUDSuppressionCoordinator() + + private let queue = DispatchQueue(label: "com.dynamicisland.hud-suppression") + private var volumeSuppressedUntil: Date? + + func suppressVolumeHUD(for interval: TimeInterval) { + guard interval > 0 else { return } + queue.sync { + let proposed = Date().addingTimeInterval(interval) + if let current = volumeSuppressedUntil { + volumeSuppressedUntil = max(current, proposed) + } else { + volumeSuppressedUntil = proposed + } + } + } + + var shouldSuppressVolumeHUD: Bool { + queue.sync { + guard let expiration = volumeSuppressedUntil else { + return false + } + if Date() < expiration { + return true + } + volumeSuppressedUntil = nil + return false + } + } +} + final class SystemVolumeController { static let shared = SystemVolumeController() @@ -46,6 +78,10 @@ final class SystemVolumeController { } func adjust(by delta: Float) { + guard delta != 0 else { return } + if isMuted { + setMuted(false) + } var newValue = currentVolume + delta newValue = max(0, min(1, newValue)) setVolume(newValue) @@ -64,18 +100,53 @@ final class SystemVolumeController { } func setVolume(_ value: Float) { - var volume = max(0, min(1, value)) - let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume) - if status != noErr { - NSLog("⚠️ Failed to set volume: \(status)") + let clamped = max(0, min(1, value)) + if isMuted && clamped > 0 { + setMuted(false) } + + let elements = volumeElements() + + if elements.isEmpty { + var volume = clamped + let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume) + if status != noErr { + NSLog("⚠️ Failed to set volume: \(status)") + } + } else { + for element in elements { + var volume = clamped + let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume) + if status != noErr { + NSLog("⚠️ Failed to set volume for element \(element): \(status)") + } else { + cache(element: element, for: kAudioDevicePropertyVolumeScalar) + } + } + } + notifyCurrentState() } func setMuted(_ muted: Bool) { var muteFlag: UInt32 = muted ? 1 : 0 - let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag) - if status != noErr { - NSLog("⚠️ Failed to set mute state: \(status)") + let elements = muteElements() + + if elements.isEmpty { + let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag) + if status != noErr { + NSLog("⚠️ Failed to set mute state: \(status)") + } + return + } + + for element in elements { + var value = muteFlag + let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value) + if status != noErr { + NSLog("⚠️ Failed to set mute state for element \(element): \(status)") + } else { + cache(element: element, for: kAudioDevicePropertyMute) + } } } @@ -159,22 +230,85 @@ final class SystemVolumeController { } private func getVolume() -> Float { - var volume = Float32(0) - let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume) - if status != noErr { - NSLog("⚠️ Unable to fetch volume: \(status)") + let elements = volumeElements() + + if elements.isEmpty { + var volume = Float32(0) + let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume) + if status != noErr { + NSLog("⚠️ Unable to fetch volume: \(status)") + } + return volume } - return volume + + var masterVolume: Float? + var accumulator: Float = 0 + var count: Float = 0 + + for element in elements { + var value = Float32(0) + let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value) + if status == noErr { + if element == kAudioObjectPropertyElementMaster { + masterVolume = value + } + accumulator += value + count += 1 + } + } + + if let masterVolume { + return masterVolume + } + + if count > 0 { + return accumulator / count + } + + var fallback = Float32(0) + let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback) + if status != noErr { + NSLog("⚠️ Unable to fetch fallback volume: \(status)") + } + return fallback } private func getMuteState() -> Bool { - var mute: UInt32 = 0 - let status = getData(selector: kAudioDevicePropertyMute, data: &mute) + let elements = muteElements() + + if elements.isEmpty { + var mute: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, data: &mute) + if status != noErr { + return false + } + return mute != 0 + } + + var retrieved = false + var allMuted = true + + for element in elements { + var value: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value) + if status == noErr { + retrieved = true + if value == 0 { + allMuted = false + } + } + } + + if retrieved { + return allMuted + } + + var fallback: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, data: &fallback) if status != noErr { - // Some devices do not support mute; treat as not muted return false } - return mute != 0 + return fallback != 0 } private func refreshPropertyElements() { @@ -264,6 +398,38 @@ final class SystemVolumeController { } return lastStatus } + + private func getData(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus { + var address = makeAddress(selector: selector, element: element) + guard propertyExists(deviceID: currentDeviceID, address: &address) else { + return kAudioHardwareUnknownPropertyError + } + var size = UInt32(MemoryLayout.size) + return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data) + } + + private func setData(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus { + var address = makeAddress(selector: selector, element: element) + guard propertyExists(deviceID: currentDeviceID, address: &address) else { + return kAudioHardwareUnknownPropertyError + } + let size = UInt32(MemoryLayout.size) + return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data) + } + + private func volumeElements() -> [AudioObjectPropertyElement] { + candidateElements.filter { element in + var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) + return propertyExists(deviceID: currentDeviceID, address: &address) + } + } + + private func muteElements() -> [AudioObjectPropertyElement] { + candidateElements.filter { element in + var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) + return propertyExists(deviceID: currentDeviceID, address: &address) + } + } } final class SystemBrightnessController { @@ -275,9 +441,17 @@ final class SystemBrightnessController { private var observers: [NSObjectProtocol] = [] private var notificationsInstalled = false private var displayID: CGDirectDisplayID = CGMainDisplayID() + private var brightnessAnimationTimer: Timer? + private var brightnessAnimationStart: Float = 0 + private var brightnessAnimationTarget: Float = 0 + private var brightnessAnimationStartDate: Date? + private let brightnessAnimationDuration: TimeInterval = 0.18 + private let brightnessAnimationSteps = 10 + private var lastEmittedBrightness: Float = 0.5 private init() { registerExternalNotifications() + lastEmittedBrightness = currentBrightness } func start() { @@ -286,6 +460,8 @@ final class SystemBrightnessController { func stop() { onBrightnessChange = nil + brightnessAnimationTimer?.invalidate() + brightnessAnimationTimer = nil } func adjust(by delta: Float) { @@ -294,17 +470,8 @@ final class SystemBrightnessController { func setBrightness(_ value: Float) { let clamped = max(0, min(1, value)) - if setBrightnessViaDisplayServices(clamped) { - notifyCurrentBrightness() - return - } - guard let service = displayService() else { return } - let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped) - IOObjectRelease(service) - if status == kIOReturnSuccess { - notifyCurrentBrightness() - } else { - NSLog("⚠️ Failed to set brightness via IODisplay: \(status)") + DispatchQueue.main.async { + self.beginBrightnessAnimation(to: clamped) } } @@ -324,10 +491,77 @@ final class SystemBrightnessController { private func notifyCurrentBrightness() { let brightness = currentBrightness - DispatchQueue.main.async { - self.onBrightnessChange?(brightness) - self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness]) + emitBrightnessChange(value: brightness) + } + + private func beginBrightnessAnimation(to target: Float) { + brightnessAnimationTimer?.invalidate() + + let start = lastEmittedBrightness + if abs(start - target) <= 0.0005 { + applyBrightness(target) + emitBrightnessChange(value: target) + return } + + brightnessAnimationStart = start + brightnessAnimationTarget = target + brightnessAnimationStartDate = Date() + + let interval = brightnessAnimationDuration / Double(brightnessAnimationSteps) + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in + guard let self else { return } + guard let startDate = self.brightnessAnimationStartDate else { + timer.invalidate() + self.brightnessAnimationTimer = nil + return + } + let elapsed = Date().timeIntervalSince(startDate) + let progress = min(elapsed / self.brightnessAnimationDuration, 1) + let eased = self.ease(progress) + let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased) + self.applyBrightness(value) + self.emitBrightnessChange(value: value) + if progress >= 1 { + timer.invalidate() + self.brightnessAnimationTimer = nil + } + } + brightnessAnimationTimer = timer + RunLoop.main.add(timer, forMode: .common) + timer.fire() + } + + private func applyBrightness(_ value: Float) { + let clamped = max(0, min(1, value)) + if setBrightnessViaDisplayServices(clamped) { + return + } + guard let service = displayService() else { return } + let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped) + IOObjectRelease(service) + if status != kIOReturnSuccess { + NSLog("⚠️ Failed to set brightness via IODisplay: \(status)") + } + } + + private func emitBrightnessChange(value: Float) { + let clamped = max(0, min(1, value)) + lastEmittedBrightness = clamped + let dispatchBlock = { + self.onBrightnessChange?(clamped) + self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": clamped]) + } + if Thread.isMainThread { + dispatchBlock() + } else { + DispatchQueue.main.async(execute: dispatchBlock) + } + } + + private func ease(_ t: Double) -> Double { + let clamped = min(max(t, 0), 1) + return 1 - pow(1 - clamped, 3) } private func displayService() -> io_service_t? { @@ -386,6 +620,7 @@ final class SystemBrightnessController { } deinit { + brightnessAnimationTimer?.invalidate() observers.forEach { DistributedNotificationCenter.default().removeObserver($0) } } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 503bf36..87f6d67 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -334,10 +334,14 @@ extension Defaults.Keys { static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) + static let showMediaOutputControl = Key("showMediaOutputControl", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) + static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) + static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) + static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) @@ -362,6 +366,7 @@ extension Defaults.Keys { static let enableGradient = Key("enableGradient", default: false) static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false) static let systemEventIndicatorUseAccent = Key("systemEventIndicatorUseAccent", default: false) + static let showProgressPercentages = Key("showProgressPercentages", default: false) // MARK: Shelf static let dynamicShelf = Key("dynamicShelf", default: true) @@ -386,6 +391,9 @@ extension Defaults.Keys { static let useColorCodedBatteryDisplay = Key("useColorCodedBatteryDisplay", default: true) static let useColorCodedVolumeDisplay = Key("useColorCodedVolumeDisplay", default: false) static let useSmoothColorGradient = Key("useSmoothColorGradient", default: true) + static let useCircularBluetoothBatteryIndicator = Key("useCircularBluetoothBatteryIndicator", default: true) + static let showBluetoothBatteryPercentageText = Key("showBluetoothBatteryPercentageText", default: true) + static let showBluetoothDeviceNameMarquee = Key("showBluetoothDeviceNameMarquee", default: true) // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index ae057bf..22775d0 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var isClipboardPopoverActive: Bool = false @Published var isColorPickerPopoverActive: Bool = false @Published var isStatsPopoverActive: Bool = false + @Published var isMediaOutputPopoverActive: Bool = false @Published var shouldRecheckHover: Bool = false let webcamManager = WebcamManager.shared diff --git a/ReadMe.md b/ReadMe.md index acf5527..da73522 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,6 +2,24 @@ Atoll logo

+

+ + GitHub stars + + + GitHub forks + + + GitHub downloads + + + Discord server + +

+ +

+ Join our Discord community — 1,200+ members +

> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll). # Atoll @@ -57,6 +75,16 @@ The application provides a comprehensive interface for monitoring system perform Clipboard manager

+### LockScreen Widgets +- **Weather** +- **Media Player** +- **Device Charging status** +- **Bluetooth Peripheral Battery status** + +

+ Lockscreen demo +

+ ### Layout and customisation - **Minimalistic mode** reducing the notch interface width from 640px to 420px, preserving essential music controls, HUDs, and timer functionality while removing secondary features for users prioritising visual simplicity. - **Per-feature toggles** enabling granular control over music controls, individual stats monitoring categories (CPU, memory, GPU, network, disk), clipboard manager display, colour picker, screen assistant, timers, and other modules.