mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #64 from Ebullioscopic/feature/lock-screen
Feature/lock screen
This commit is contained in:
commit
e3c33e0e08
24 changed files with 2705 additions and 372 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
3
.github/assets/lockscreen.gif
vendored
Normal file
3
.github/assets/lockscreen.gif
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220
|
||||
size 90875738
|
||||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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." : {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
245
DynamicIsland/managers/AudioRouteManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
65
DynamicIsland/managers/MacBatteryManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
ReadMe.md
28
ReadMe.md
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue