Merge pull request #64 from Ebullioscopic/feature/lock-screen

Feature/lock screen
This commit is contained in:
Hariharan Mudaliar 2025-10-27 01:58:37 +05:30 • committed by GitHub
commit e3c33e0e08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2705 additions and 372 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.gif filter=lfs diff=lfs merge=lfs -text

3
.github/assets/lockscreen.gif vendored Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220
size 90875738

View file

@ -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 = "";

View file

@ -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
//
})
@ -754,7 +753,8 @@ struct ContentView: View {
return vm.isBatteryPopoverActive ||
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive
vm.isStatsPopoverActive ||
vm.isMediaOutputPopoverActive
}
// Helper to prevent rapid haptic feedback

View file

@ -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 {
@ -405,6 +414,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
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
// Force sneak peek to standard mode when minimalistic UI is enabled
@ -477,10 +490,8 @@ 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(
@ -490,10 +501,8 @@ 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
@ -529,112 +538,72 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
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 }
KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
// Only execute if shortcuts are enabled
_ = 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()
}

View file

@ -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." : {

View file

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

View file

@ -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 {
if showBluetoothDeviceNameMarquee {
MarqueeText(
$displayName,
font: .system(size: 13, weight: .medium),
nsFont: .body,
textColor: .white,
minDuration: 0.5,
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
minDuration: 0.2,
frameWidth: infoWidth
)
.onAppear {
displayName = Type2Name(type)
}
.onChange(of: type) { _, _ in
displayName = Type2Name(type)
}
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
displayName = Type2Name(type)
}
} 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,45 +150,40 @@ 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)
}
if showBluetoothBatteryPercentageText {
Text("\(Int(value * 100))%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white)
.lineLimit(1)
} else {
// No battery info available
Text("Connected")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.lineLimit(1)
}
}
.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) {
Group {
if type == .volume {
Group {
if value.isZero {
Text("muted")
.font(.caption)
.fontWeight(.medium)
@ -173,14 +191,104 @@ struct InlineHUD: View {
.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 {
HStack(spacing: 6) {
DraggableProgressBar(value: $value)
PercentageLabel(value: value, isVisible: showProgressPercentages)
}
}
}
.frame(maxWidth: .infinity, alignment: .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 {

View file

@ -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,22 +204,7 @@ 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
}
}
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 {
@ -165,7 +300,7 @@ 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 barWidth = max(3.0, computed)
let activeCount = Int(round(value * CGFloat(segmentCount)))
HStack(spacing: spacing) {

View file

@ -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,8 +360,22 @@ 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
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()
}
@ -348,13 +390,15 @@ struct LockScreenMusicPanel: View {
musicManager.nextTrack()
}
// Always show repeat on lock screen
if showMediaOutputControl {
mediaOutputControlButton
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
}
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
}
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 {

View file

@ -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(
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
)
}
return String(
)
} 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("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
bluetooth.deviceName,
bluetooth.batteryLevel
)
}
}

View file

@ -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,11 +183,15 @@ struct MinimalisticMusicPlayerView: View {
}
if Defaults[.showShuffleAndRepeat] {
if showMediaOutputControl {
MinimalisticMediaOutputButton()
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
Task { await musicManager.toggleRepeat() }
}
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 2)
}
@ -216,6 +221,60 @@ 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 {

View file

@ -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,11 +193,15 @@ struct MusicControlsView: View {
MusicManager.shared.nextTrack()
}
if showShuffleAndRepeat {
if showMediaOutputControl {
MediaOutputPickerButton()
} else {
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
MusicManager.shared.toggleRepeat()
}
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
@ -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<AnyCancellable> = []
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

View file

@ -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)
.disabled(colorCodingDisabled)
if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
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")
}

View file

@ -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<AudioDeviceID>.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<AudioBufferList>.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<CFString?>.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<UInt32>.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<AudioDeviceID>.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<AudioDeviceID>.size),
&target
)
if status == noErr {
DispatchQueue.main.async { [weak self] in
self?.activeDeviceID = deviceID
}
refreshDevices()
}
}
}

View file

@ -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<AnyCancellable>()
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<String> = []
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)")
@ -202,11 +214,17 @@ class BluetoothAudioManager: ObservableObject {
lastConnectedDevice = audioDevice
isBluetoothAudioConnected = true
refreshBatteryLevelsForConnectedDevices()
// Show HUD for new connection
if let refreshedDevice = connectedDevices.last {
showDeviceConnectedHUD(refreshedDevice)
} else {
showDeviceConnectedHUD(audioDevice)
}
}
}
}
/// Updates the list of connected devices (for disconnections)
private func updateConnectedDevices() {
@ -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
updateBatteryStatuses()
// Placeholder: Return nil if not available
// TODO: Implement actual battery level reading if device supports it
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
}
@ -373,6 +411,590 @@ class BluetoothAudioManager: ObservableObject {
}
}
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<String> = []
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<String> = []
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
/// Shows HUD notification for newly connected audio device
@ -382,12 +1004,20 @@ 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(
status: true,
@ -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 {

View file

@ -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 {
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)
}

View file

@ -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<AnyCancellable>()
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()
}
return
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 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<Void, Never>] = [
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<Void, Never>] = [
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
)
}
}

View file

@ -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"
}
}

View file

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

View file

@ -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,19 +100,54 @@ final class SystemVolumeController {
}
func setVolume(_ value: Float) {
var volume = max(0, min(1, value))
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 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)
}
}
}
// MARK: - Private
@ -159,6 +230,9 @@ final class SystemVolumeController {
}
private func getVolume() -> Float {
let elements = volumeElements()
if elements.isEmpty {
var volume = Float32(0)
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
if status != noErr {
@ -167,16 +241,76 @@ final class SystemVolumeController {
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 {
let elements = muteElements()
if elements.isEmpty {
var mute: UInt32 = 0
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
if status != noErr {
// Some devices do not support mute; treat as not muted
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 {
return false
}
return fallback != 0
}
private func refreshPropertyElements() {
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
@ -264,6 +398,38 @@ final class SystemVolumeController {
}
return lastStatus
}
private func getData<T>(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<T>.size)
return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
}
private func setData<T>(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<T>.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) }
}
}

View file

@ -334,10 +334,14 @@ extension Defaults.Keys {
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
static let waitInterval = Key<Double>("waitInterval", default: 3)
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
// Enable lock screen media widget (shows the standalone panel when screen is locked)
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
@ -362,6 +366,7 @@ extension Defaults.Keys {
static let enableGradient = Key<Bool>("enableGradient", default: false)
static let systemEventIndicatorShadow = Key<Bool>("systemEventIndicatorShadow", default: false)
static let systemEventIndicatorUseAccent = Key<Bool>("systemEventIndicatorUseAccent", default: false)
static let showProgressPercentages = Key<Bool>("showProgressPercentages", default: false)
// MARK: Shelf
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
@ -386,6 +391,9 @@ extension Defaults.Keys {
static let useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true)
static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: true)
static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: true)
// MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)

View file

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

View file

@ -2,6 +2,24 @@
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="120">
</p>
<p align="center">
<a href="https://github.com/Ebullioscopic/Atoll/stargazers">
<img src="https://img.shields.io/github/stars/Ebullioscopic/Atoll?style=social" alt="GitHub stars"/>
</a>
<a href="https://github.com/Ebullioscopic/Atoll/network/members">
<img src="https://img.shields.io/github/forks/Ebullioscopic/Atoll?style=social" alt="GitHub forks"/>
</a>
<a href="https://github.com/Ebullioscopic/Atoll/releases">
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
</a>
<a href="https://discord.gg/zvjB4tJBkt">
<img src="https://img.shields.io/discord/1120176554840510494?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
</a>
</p>
<p align="center">
<a href="https://discord.gg/zvjB4tJBkt">Join our Discord community</a> — <b>1,200+ members</b>
</p>
> **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
<img src=".github/assets/atoll-clipboard.png" alt="Clipboard manager" width="320">
</p>
### LockScreen Widgets
- **Weather**
- **Media Player**
- **Device Charging status**
- **Bluetooth Peripheral Battery status**
<p align="center">
<img src=".github/assets/lockscreen.gif" alt="Lockscreen demo" width="720">
</p>
### 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.