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;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
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_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||||
|
|
@ -412,7 +414,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "1.0.1-beta";
|
MARKETING_VERSION = "1.0.2-beta";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -449,6 +451,8 @@
|
||||||
);
|
);
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
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_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||||
|
|
@ -460,7 +464,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "1.0.1-beta";
|
MARKETING_VERSION = "1.0.2-beta";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
|
||||||
|
|
@ -200,14 +200,14 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ struct ContentView: View {
|
||||||
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
||||||
// Recheck hover state when popovers are closed
|
// Recheck hover state when popovers are closed
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +320,9 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.sensoryFeedback(.alignment, trigger: haptics)
|
.sensoryFeedback(.alignment, trigger: haptics)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
SettingsLink(label: {
|
Button("Settings") {
|
||||||
Text("Settings")
|
SettingsWindowController.shared.showWindow()
|
||||||
})
|
}
|
||||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
|
||||||
// Button("Edit") { // Doesnt work....
|
// Button("Edit") { // Doesnt work....
|
||||||
// let dn = DynamicNotch(content: EditPanelView())
|
// let dn = DynamicNotch(content: EditPanelView())
|
||||||
// dn.toggle()
|
// dn.toggle()
|
||||||
|
|
@ -404,7 +403,7 @@ struct ContentView: View {
|
||||||
.frame(width: 76, alignment: .trailing)
|
.frame(width: 76, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
.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)
|
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
} 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.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
|
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|
@ -751,10 +750,11 @@ struct ContentView: View {
|
||||||
|
|
||||||
// Helper function to check if any popovers are active
|
// Helper function to check if any popovers are active
|
||||||
private func hasAnyActivePopovers() -> Bool {
|
private func hasAnyActivePopovers() -> Bool {
|
||||||
return vm.isBatteryPopoverActive ||
|
return vm.isBatteryPopoverActive ||
|
||||||
vm.isClipboardPopoverActive ||
|
vm.isClipboardPopoverActive ||
|
||||||
vm.isColorPickerPopoverActive ||
|
vm.isColorPickerPopoverActive ||
|
||||||
vm.isStatsPopoverActive
|
vm.isStatsPopoverActive ||
|
||||||
|
vm.isMediaOutputPopoverActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to prevent rapid haptic feedback
|
// Helper to prevent rapid haptic feedback
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,15 @@ struct DynamicNotchApp: App {
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commands: some Commands {
|
||||||
|
CommandGroup(replacing: .appSettings) {
|
||||||
|
Button("Settings…") {
|
||||||
|
SettingsWindowController.shared.showWindow()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(",", modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
@ -404,6 +413,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
|
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
|
||||||
self?.debouncedUpdateWindowSize()
|
self?.debouncedUpdateWindowSize()
|
||||||
}.store(in: &cancellables)
|
}.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
|
// Observe minimalistic UI setting changes - trigger window resize
|
||||||
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
|
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
|
||||||
|
|
@ -477,12 +490,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
self, selector: #selector(onScreenUnlocked(_:)),
|
self, selector: #selector(onScreenUnlocked(_:)),
|
||||||
name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil)
|
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 }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
self.coordinator.toggleSneakPeek(
|
self.coordinator.toggleSneakPeek(
|
||||||
status: !self.coordinator.sneakPeek.show,
|
status: !self.coordinator.sneakPeek.show,
|
||||||
type: .music,
|
type: .music,
|
||||||
|
|
@ -490,14 +501,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
_ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
let mouseLocation = NSEvent.mouseLocation
|
let mouseLocation = NSEvent.mouseLocation
|
||||||
|
|
||||||
var viewModel = self.vm
|
var viewModel = self.vm
|
||||||
|
|
||||||
if Defaults[.showOnAllDisplays] {
|
if Defaults[.showOnAllDisplays] {
|
||||||
|
|
@ -510,131 +519,91 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.closeNotchWorkItem?.cancel()
|
self.closeNotchWorkItem?.cancel()
|
||||||
self.closeNotchWorkItem = nil
|
self.closeNotchWorkItem = nil
|
||||||
|
|
||||||
switch viewModel.notchState {
|
switch viewModel.notchState {
|
||||||
case .closed:
|
case .closed:
|
||||||
viewModel.open()
|
viewModel.open()
|
||||||
|
|
||||||
let workItem = DispatchWorkItem { [weak viewModel] in
|
let workItem = DispatchWorkItem { [weak viewModel] in
|
||||||
viewModel?.close()
|
viewModel?.close()
|
||||||
}
|
}
|
||||||
self.closeNotchWorkItem = workItem
|
self.closeNotchWorkItem = workItem
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
||||||
case .open:
|
case .open:
|
||||||
viewModel.close()
|
viewModel.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in
|
_ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) {
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
// Only start timer if the timer feature is enabled
|
|
||||||
guard Defaults[.enableTimerFeature] else { return }
|
guard Defaults[.enableTimerFeature] else { return }
|
||||||
|
|
||||||
// Start a 5-minute demo timer
|
|
||||||
TimerManager.shared.startDemoTimer(duration: 300)
|
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 }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
// Only open clipboard if the feature is enabled
|
|
||||||
guard Defaults[.enableClipboardManager] else { return }
|
guard Defaults[.enableClipboardManager] else { return }
|
||||||
|
|
||||||
// Start clipboard monitoring if not already running
|
|
||||||
if !ClipboardManager.shared.isMonitoring {
|
if !ClipboardManager.shared.isMonitoring {
|
||||||
ClipboardManager.shared.startMonitoring()
|
ClipboardManager.shared.startMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle keyboard shortcut based on display mode
|
|
||||||
switch Defaults[.clipboardDisplayMode] {
|
switch Defaults[.clipboardDisplayMode] {
|
||||||
case .panel:
|
case .panel:
|
||||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||||
case .popover:
|
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 {
|
if self.vm.notchState == .closed {
|
||||||
self.vm.open()
|
self.vm.open()
|
||||||
// Wait a moment for the notch to open, then show popover
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notch is already open, toggle popover immediately
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in
|
_ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) {
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
// Only open color picker panel if the feature is enabled
|
|
||||||
guard Defaults[.enableColorPickerFeature] else { return }
|
guard Defaults[.enableColorPickerFeature] else { return }
|
||||||
|
|
||||||
// Toggle color picker panel
|
|
||||||
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
_ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
// Only open screen assistant if the feature is enabled
|
|
||||||
guard Defaults[.enableScreenAssistant] else { return }
|
guard Defaults[.enableScreenAssistant] else { return }
|
||||||
|
|
||||||
// Handle keyboard shortcut based on display mode
|
|
||||||
switch Defaults[.screenAssistantDisplayMode] {
|
switch Defaults[.screenAssistantDisplayMode] {
|
||||||
case .panel:
|
case .panel:
|
||||||
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
|
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
|
||||||
case .popover:
|
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 {
|
if self.vm.notchState == .closed {
|
||||||
self.vm.open()
|
self.vm.open()
|
||||||
// Wait a moment for the notch to open, then show popover
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notch is already open, toggle popover immediately
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
|
KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
|
||||||
guard let self = self else { return }
|
|
||||||
|
_ = KeyboardShortcuts.onKeyDown(for: .statsPanel) {
|
||||||
// Only execute if shortcuts are enabled
|
|
||||||
guard Defaults[.enableShortcuts] else { return }
|
guard Defaults[.enableShortcuts] else { return }
|
||||||
|
|
||||||
// Only open stats panel if both stats feature and panel are enabled
|
|
||||||
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
|
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
|
||||||
|
|
||||||
// Start stats monitoring if not already running
|
|
||||||
if !StatsManager.shared.isMonitoring {
|
if !StatsManager.shared.isMonitoring {
|
||||||
StatsManager.shared.startMonitoring()
|
StatsManager.shared.startMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle stats panel
|
|
||||||
StatsPanelManager.shared.toggleStatsPanel()
|
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" : {
|
"Battery Information" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -3464,6 +3473,17 @@
|
||||||
"comment" : "A section header that lists Bluetooth audio devices.",
|
"comment" : "A section header that lists Bluetooth audio devices.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Boost your productivity with Clipboard Manager" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -4609,8 +4629,9 @@
|
||||||
"Color Picker Panel:" : {
|
"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" : {
|
"Color-Coded Progress Bars" : {
|
||||||
"comment" : "A section header that lists the options for color-coded progress bars.",
|
"comment" : "A section header that lists the options for color-coded progress bars.",
|
||||||
|
|
@ -4721,9 +4742,6 @@
|
||||||
},
|
},
|
||||||
"Configuration" : {
|
"Configuration" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Connected" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Continue" : {
|
"Continue" : {
|
||||||
"localizations" : {
|
"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)" : {
|
"Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
|
||||||
"comment" : "A description of the \"Discrete\" color-coded progress bar style, including the colors used for battery and volume levels.",
|
"comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Disk Read" : {
|
"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" : {
|
"Media playback live activity" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -10761,6 +10783,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Mic %@" : {
|
"Mic %@" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"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" : {
|
"Microphone" : {
|
||||||
"comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
|
"comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -11805,6 +11836,10 @@
|
||||||
"comment" : "A message displayed when there are no active network interfaces to display.",
|
"comment" : "A message displayed when there are no active network interfaces to display.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"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." : {
|
"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." : {
|
"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" : {
|
"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." : {
|
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||||
|
|
||||||
|
|
@ -14465,6 +14512,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Settings…" : {
|
||||||
|
"comment" : "A command that shows the settings window.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Shelf" : {
|
"Shelf" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"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)" : {
|
"Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." : {
|
||||||
"comment" : "A detailed description of the \"Smooth\" color-coded gradient display feature.",
|
"comment" : "A description of how the color-coding feature works.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
|
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,31 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
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 {
|
struct ColorCodedProgressBar: View {
|
||||||
let value: CGFloat // 0.0 to 1.0
|
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 width: CGFloat
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
|
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.value = min(max(value, 0), 1) // Clamp between 0 and 1
|
||||||
self.reversed = reversed
|
self.mode = mode
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
self.smoothGradient = smoothGradient
|
self.smoothGradient = smoothGradient
|
||||||
|
|
@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View {
|
||||||
|
|
||||||
// Filled track with color gradient or solid color
|
// Filled track with color gradient or solid color
|
||||||
RoundedRectangle(cornerRadius: height / 2)
|
RoundedRectangle(cornerRadius: height / 2)
|
||||||
.fill(smoothGradient ?
|
.fill(fillStyle)
|
||||||
AnyShapeStyle(LinearGradient(
|
|
||||||
gradient: gradientForValue,
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)) :
|
|
||||||
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
|
|
||||||
)
|
|
||||||
.frame(width: width * value, height: height)
|
.frame(width: width * value, height: height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a gradient based on the current value
|
private var fillStyle: AnyShapeStyle {
|
||||||
private var gradientForValue: Gradient {
|
let intensity = mode.intensity(for: value)
|
||||||
let color = colorForPercentage(value, reversed: reversed)
|
if smoothGradient {
|
||||||
|
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
|
||||||
// Create a gradient that transitions smoothly
|
} else {
|
||||||
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
|
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View {
|
||||||
extension ColorCodedProgressBar {
|
extension ColorCodedProgressBar {
|
||||||
/// Creates a battery-style progress bar (green at high, red at low)
|
/// 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 {
|
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)
|
/// 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 {
|
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(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
|
||||||
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
||||||
@Default(.progressBarStyle) var progressBarStyle
|
@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
|
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
||||||
|
|
||||||
@State private var displayName: String = ""
|
@State private var displayName: String = ""
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
HStack(spacing: 5) {
|
||||||
Group {
|
Group {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -77,22 +107,15 @@ struct InlineHUD: View {
|
||||||
|
|
||||||
// Use marquee text for device names to handle long names
|
// Use marquee text for device names to handle long names
|
||||||
if type == .bluetoothAudio {
|
if type == .bluetoothAudio {
|
||||||
MarqueeText(
|
if showBluetoothDeviceNameMarquee {
|
||||||
$displayName,
|
MarqueeText(
|
||||||
font: .system(size: 13, weight: .medium),
|
$displayName,
|
||||||
nsFont: .body,
|
font: .system(size: 13, weight: .medium),
|
||||||
textColor: .white,
|
nsFont: .body,
|
||||||
minDuration: 0.5,
|
textColor: .white,
|
||||||
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 {
|
} else {
|
||||||
Text(Type2Name(type))
|
Text(Type2Name(type))
|
||||||
|
|
@ -103,7 +126,7 @@ struct InlineHUD: View {
|
||||||
.contentTransition(.numericText())
|
.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()
|
Rectangle()
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
|
|
@ -127,62 +150,147 @@ struct InlineHUD: View {
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
.contentTransition(.interpolate)
|
.contentTransition(.interpolate)
|
||||||
} else if (type == .bluetoothAudio) {
|
} else if (type == .bluetoothAudio) {
|
||||||
// Bluetooth device battery display
|
if hasBatteryLevel {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
||||||
if value > 0 {
|
if useCircularIndicator {
|
||||||
// Show color-coded battery bar if battery info available
|
CircularBatteryIndicator(
|
||||||
// Color-coding disabled for segmented or hierarchical mode
|
value: value,
|
||||||
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
|
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||||
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
|
smoothGradient: useSmoothColorGradient
|
||||||
|
)
|
||||||
|
.allowsHitTesting(false)
|
||||||
} else {
|
} else {
|
||||||
DraggableProgressBar(value: .constant(value))
|
LinearBatteryIndicator(
|
||||||
.frame(width: 60)
|
value: value,
|
||||||
|
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||||
|
smoothGradient: useSmoothColorGradient
|
||||||
|
)
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
Text("\(Int(value * 100))%")
|
|
||||||
.font(.caption)
|
if showBluetoothBatteryPercentageText {
|
||||||
.fontWeight(.medium)
|
Text("\(Int(value * 100))%")
|
||||||
.foregroundStyle(.white)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Volume and brightness displays
|
||||||
|
Group {
|
||||||
|
if type == .volume {
|
||||||
|
Group {
|
||||||
|
if value.isZero {
|
||||||
|
Text("muted")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
.lineLimit(1)
|
||||||
|
.allowsTightening(true)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
DraggableProgressBar(value: $value, colorMode: .volume)
|
||||||
|
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.smooth(duration: 0.2), value: value.isZero)
|
||||||
} else {
|
} else {
|
||||||
// No battery info available
|
HStack(spacing: 6) {
|
||||||
Text("Connected")
|
DraggableProgressBar(value: $value)
|
||||||
.font(.caption)
|
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||||
.fontWeight(.medium)
|
}
|
||||||
.foregroundStyle(.gray)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
} else {
|
|
||||||
// Volume and brightness displays
|
|
||||||
HStack {
|
|
||||||
// Color-coding only available in gradient mode, not hierarchical or segmented
|
|
||||||
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
|
|
||||||
// Color-coded volume (reversed: red at high, green at low)
|
|
||||||
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
|
|
||||||
} else {
|
|
||||||
// Default draggable progress bar
|
|
||||||
DraggableProgressBar(value: $value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == .volume && value.isZero) {
|
|
||||||
Text("muted")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(.gray)
|
|
||||||
.lineLimit(1)
|
|
||||||
.allowsTightening(true)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.trailing, 4)
|
.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)
|
.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 {
|
func SpeakerSymbol(_ value: CGFloat) -> String {
|
||||||
switch(value) {
|
switch(value) {
|
||||||
case 0:
|
case 0:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View {
|
||||||
@Binding var icon: String
|
@Binding var icon: String
|
||||||
let showSlider: Bool = false
|
let showSlider: Bool = false
|
||||||
var sendEventBack: (CGFloat) -> Void
|
var sendEventBack: (CGFloat) -> Void
|
||||||
|
@Default(.showProgressPercentages) private var showProgressPercentages
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
|
|
@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View {
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
if (eventType != .mic) {
|
switch eventType {
|
||||||
DraggableProgressBar(value: $value)
|
case .mic:
|
||||||
} else {
|
Text(value > 0 ? "Mic unmuted" : "Mic muted")
|
||||||
Text("Mic \(value > 0 ? "unmuted" : "muted")")
|
|
||||||
.foregroundStyle(.gray)
|
.foregroundStyle(.gray)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.allowsTightening(true)
|
.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)
|
.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 {
|
struct DraggableProgressBar: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@Binding var value: CGFloat
|
@Binding var value: CGFloat
|
||||||
|
var colorMode: ProgressColorMode? = nil
|
||||||
|
|
||||||
@State private var isDragging = false
|
@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 {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
Group {
|
Group {
|
||||||
if Defaults[.progressBarStyle] == .segmented {
|
if progressBarStyle == .segmented {
|
||||||
// Segmented progress bar - completely different layout
|
// Segmented progress bar - completely different layout
|
||||||
SegmentedProgressContent(value: value, geometry: geo)
|
SegmentedProgressContent(value: value, geometry: geo)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -107,23 +204,8 @@ struct DraggableProgressBar: View {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(.tertiary)
|
.fill(.tertiary)
|
||||||
Group {
|
progressFill(width: geo.size.width)
|
||||||
switch Defaults[.progressBarStyle] {
|
.opacity(value.isZero ? 0 : 1)
|
||||||
case .gradient:
|
|
||||||
Capsule()
|
|
||||||
.fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
|
|
||||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
|
||||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
|
||||||
case .hierarchical:
|
|
||||||
Capsule()
|
|
||||||
.fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
|
|
||||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
|
||||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
|
||||||
case .segmented:
|
|
||||||
EmptyView() // This case won't be reached due to the outer if condition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.opacity(value.isZero ? 0 : 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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))
|
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 {
|
struct SegmentedProgressContent: View {
|
||||||
|
|
@ -164,8 +299,8 @@ struct SegmentedProgressContent: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let spacing: CGFloat = 1.5
|
let spacing: CGFloat = 1.5
|
||||||
let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
|
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)))
|
let activeCount = Int(round(value * CGFloat(segmentCount)))
|
||||||
|
|
||||||
HStack(spacing: spacing) {
|
HStack(spacing: spacing) {
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View {
|
||||||
static let expandedSize = CGSize(width: 720, height: 340)
|
static let expandedSize = CGSize(width: 720, height: 340)
|
||||||
|
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@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 sliderValue: Double = 0
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var isActive = true
|
@State private var isActive = true
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
@State private var isVolumeSliderVisible = false
|
||||||
@State private var collapseWorkItem: DispatchWorkItem?
|
@State private var collapseWorkItem: DispatchWorkItem?
|
||||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||||
@Default(.lockScreenShowAppIcon) var showAppIcon
|
@Default(.lockScreenShowAppIcon) var showAppIcon
|
||||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||||
|
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||||
|
|
||||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||||
private let expandedPanelCornerRadius: CGFloat = 52
|
private let expandedPanelCornerRadius: CGFloat = 52
|
||||||
|
|
@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View {
|
||||||
private let expandedAlbumArtCornerRadius: CGFloat = 60
|
private let expandedAlbumArtCornerRadius: CGFloat = 60
|
||||||
private let expandedContentSpacing: CGFloat = 40
|
private let expandedContentSpacing: CGFloat = 40
|
||||||
private let collapseTimeout: TimeInterval = 5
|
private let collapseTimeout: TimeInterval = 5
|
||||||
|
private let collapsedSliderExtraHeight: CGFloat = 72
|
||||||
|
private let expandedSliderExtraHeight: CGFloat = 88
|
||||||
|
|
||||||
private var currentSize: CGSize {
|
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 {
|
private var panelCornerRadius: CGFloat {
|
||||||
|
|
@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View {
|
||||||
panelCore
|
panelCore
|
||||||
.frame(width: currentSize.width, height: currentSize.height)
|
.frame(width: currentSize.width, height: currentSize.height)
|
||||||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
.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 {
|
.onAppear {
|
||||||
sliderValue = musicManager.elapsedTime
|
sliderValue = musicManager.elapsedTime
|
||||||
isActive = true
|
isActive = true
|
||||||
logPanelAppearance()
|
logPanelAppearance()
|
||||||
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
|
LockScreenPanelManager.shared.updatePanelSize(
|
||||||
|
expanded: isExpanded,
|
||||||
|
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
routeManager.refreshDevices()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
isActive = false
|
isActive = false
|
||||||
cancelCollapseTimer()
|
cancelCollapseTimer()
|
||||||
|
isVolumeSliderVisible = false
|
||||||
}
|
}
|
||||||
.onChange(of: isExpanded) { _, expanded in
|
.onChange(of: isExpanded) { _, expanded in
|
||||||
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
|
LockScreenPanelManager.shared.updatePanelSize(
|
||||||
|
expanded: expanded,
|
||||||
|
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onChange(of: showMediaOutputControl) { _, enabled in
|
||||||
|
if !enabled {
|
||||||
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||||
|
isVolumeSliderVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LockScreenPanelManager.shared.updatePanelSize(
|
||||||
|
expanded: isExpanded,
|
||||||
|
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,29 +360,45 @@ struct LockScreenMusicPanel: View {
|
||||||
private func playbackControls(alignment: Alignment) -> some View {
|
private func playbackControls(alignment: Alignment) -> some View {
|
||||||
let spacing: CGFloat = isExpanded ? 24 : 20
|
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||||
|
|
||||||
return HStack(spacing: spacing) {
|
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
|
||||||
// Always show shuffle on lock screen
|
controlsRow(alignment: alignment, spacing: spacing)
|
||||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
|
||||||
musicManager.toggleShuffle()
|
if shouldShowVolumeSlider {
|
||||||
}
|
volumeSlider
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
controlButton(icon: "backward.fill", size: 18) {
|
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||||||
musicManager.previousTrack()
|
|
||||||
}
|
|
||||||
|
|
||||||
playPauseButton
|
|
||||||
|
|
||||||
controlButton(icon: "forward.fill", size: 18) {
|
|
||||||
musicManager.nextTrack()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always show repeat on lock screen
|
|
||||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
|
||||||
musicManager.toggleRepeat()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.padding(.top, isExpanded ? 6 : 2)
|
.padding(.top, isExpanded ? 6 : 2)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||||
|
musicManager.toggleShuffle()
|
||||||
|
}
|
||||||
|
|
||||||
|
controlButton(icon: "backward.fill", size: 18) {
|
||||||
|
musicManager.previousTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
playPauseButton
|
||||||
|
|
||||||
|
controlButton(icon: "forward.fill", size: 18) {
|
||||||
|
musicManager.nextTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMediaOutputControl {
|
||||||
|
mediaOutputControlButton
|
||||||
|
} else {
|
||||||
|
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||||
|
musicManager.toggleRepeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playPauseButton: some View {
|
private var playPauseButton: some View {
|
||||||
|
|
@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View {
|
||||||
.buttonStyle(PlainButtonStyle())
|
.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 {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
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
|
@ViewBuilder
|
||||||
private var panelBackground: some View {
|
private var panelBackground: some View {
|
||||||
if enableBlur {
|
if enableBlur {
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,25 @@ import SwiftUI
|
||||||
struct LockScreenWeatherWidget: View {
|
struct LockScreenWeatherWidget: View {
|
||||||
let snapshot: LockScreenWeatherSnapshot
|
let snapshot: LockScreenWeatherSnapshot
|
||||||
|
|
||||||
|
private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
Image(systemName: snapshot.symbolName)
|
if let charging = snapshot.charging {
|
||||||
.font(.system(size: 26, weight: .medium))
|
chargingSegment(for: charging)
|
||||||
.symbolRenderingMode(.hierarchical)
|
}
|
||||||
Text(snapshot.temperatureText)
|
|
||||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
if let bluetooth = snapshot.bluetooth {
|
||||||
.kerning(-0.4)
|
bluetoothSegment(for: bluetooth)
|
||||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
}
|
||||||
Text("•")
|
|
||||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
weatherSegment
|
||||||
Text(locationName)
|
|
||||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
if shouldShowLocation {
|
||||||
.foregroundStyle(Color.white.opacity(0.65))
|
locationSegment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.foregroundStyle(Color.white.opacity(0.65))
|
.foregroundStyle(Color.white.opacity(0.65))
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
@ -28,20 +31,151 @@ struct LockScreenWeatherWidget: View {
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.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 {
|
private var accessibilityLabel: String {
|
||||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
var components: [String] = []
|
||||||
return String(
|
|
||||||
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
|
if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||||
snapshot.description,
|
components.append(
|
||||||
snapshot.temperatureText,
|
String(
|
||||||
locationName
|
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
|
||||||
|
snapshot.description,
|
||||||
|
snapshot.temperatureText,
|
||||||
|
locationName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
components.append(
|
||||||
|
String(
|
||||||
|
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
|
||||||
|
snapshot.description,
|
||||||
|
snapshot.temperatureText
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let charging = snapshot.charging {
|
||||||
|
components.append(accessibilityChargingText(for: charging))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bluetooth = snapshot.bluetooth {
|
||||||
|
components.append(accessibilityBluetoothText(for: bluetooth))
|
||||||
|
}
|
||||||
|
|
||||||
|
return components.joined(separator: ". ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||||
|
if let minutes = charging.minutesRemaining, minutes > 0 {
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.allowedUnits = [.hour, .minute]
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes"
|
||||||
|
return String(
|
||||||
|
format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"),
|
||||||
|
duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if charging.isPluggedIn && !charging.isCharging {
|
||||||
|
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
|
||||||
return String(
|
return String(
|
||||||
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
|
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
|
||||||
snapshot.description,
|
bluetooth.deviceName,
|
||||||
snapshot.temperatureText
|
bluetooth.batteryLevel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import Defaults
|
||||||
struct MinimalisticMusicPlayerView: View {
|
struct MinimalisticMusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
|
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -182,8 +183,12 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
if showMediaOutputControl {
|
||||||
Task { await musicManager.toggleRepeat() }
|
MinimalisticMediaOutputButton()
|
||||||
|
} else {
|
||||||
|
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||||
|
Task { await musicManager.toggleRepeat() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +221,61 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
action: action
|
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 {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
case .off: return "repeat"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ struct MusicControlsView: View {
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
let showShuffleAndRepeat: Bool
|
let showShuffleAndRepeat: Bool
|
||||||
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|
@ -192,8 +193,12 @@ struct MusicControlsView: View {
|
||||||
MusicManager.shared.nextTrack()
|
MusicManager.shared.nextTrack()
|
||||||
}
|
}
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
if showMediaOutputControl {
|
||||||
MusicManager.shared.toggleRepeat()
|
MediaOutputPickerButton()
|
||||||
|
} else {
|
||||||
|
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
||||||
|
MusicManager.shared.toggleRepeat()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -382,6 +387,239 @@ struct CustomSlider: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MediaOutputPickerButton: View {
|
||||||
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
@State private var isPopoverPresented = false
|
||||||
|
@State private var isHoveringPopover = false
|
||||||
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
||||||
|
isPopoverPresented.toggle()
|
||||||
|
if isPopoverPresented {
|
||||||
|
routeManager.refreshDevices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Media output")
|
||||||
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||||
|
MediaOutputSelectorPopover(
|
||||||
|
routeManager: routeManager,
|
||||||
|
volumeModel: volumeModel,
|
||||||
|
onHoverChanged: { hovering in
|
||||||
|
isHoveringPopover = hovering
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
isPopoverPresented = false
|
||||||
|
isHoveringPopover = false
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
routeManager.refreshDevices()
|
||||||
|
}
|
||||||
|
.onChange(of: isPopoverPresented) { _, presented in
|
||||||
|
if !presented {
|
||||||
|
isHoveringPopover = false
|
||||||
|
}
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
vm.isMediaOutputPopoverActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonIcon: String {
|
||||||
|
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePopoverActivity() {
|
||||||
|
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaOutputSelectorPopover: View {
|
||||||
|
@ObservedObject var routeManager: AudioRouteManager
|
||||||
|
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
||||||
|
var onHoverChanged: (Bool) -> Void
|
||||||
|
var dismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
volumeSection
|
||||||
|
Divider()
|
||||||
|
devicesSection
|
||||||
|
}
|
||||||
|
.frame(width: 240)
|
||||||
|
.padding(16)
|
||||||
|
.onHover { hovering in
|
||||||
|
onHoverChanged(hovering)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
onHoverChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var volumeSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
volumeModel.toggleMute()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: volumeIconName)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.secondary.opacity(0.18))
|
||||||
|
)
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(volumeModel.level) },
|
||||||
|
set: { newValue in
|
||||||
|
volumeModel.setVolume(Float(newValue))
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0 ... 1
|
||||||
|
)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Output volume")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(volumePercentage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var devicesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Output devices")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if routeManager.devices.isEmpty {
|
||||||
|
Text("No audio outputs available")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(routeManager.devices) { device in
|
||||||
|
Button {
|
||||||
|
routeManager.select(device: device)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: device.iconName)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Text(device.name)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if device.id == routeManager.activeDeviceID {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 180)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var volumeIconName: String {
|
||||||
|
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||||
|
return "speaker.slash.fill"
|
||||||
|
} else if volumeModel.level < 0.33 {
|
||||||
|
return "speaker.wave.1.fill"
|
||||||
|
} else if volumeModel.level < 0.66 {
|
||||||
|
return "speaker.wave.2.fill"
|
||||||
|
}
|
||||||
|
return "speaker.wave.3.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var volumePercentage: String {
|
||||||
|
"\(Int(round(volumeModel.level * 100)))%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MediaOutputVolumeViewModel: ObservableObject {
|
||||||
|
@Published var level: Float
|
||||||
|
@Published var isMuted: Bool
|
||||||
|
|
||||||
|
private let controller: SystemVolumeController
|
||||||
|
private var cancellables: Set<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 {
|
#Preview {
|
||||||
NotchHomeView(
|
NotchHomeView(
|
||||||
albumArtNamespace: Namespace().wrappedValue
|
albumArtNamespace: Namespace().wrappedValue
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,10 @@ struct GeneralSettings: View {
|
||||||
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
||||||
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
|
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
|
||||||
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
|
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
|
||||||
|
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
|
||||||
|
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
|
||||||
|
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
|
||||||
|
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -324,6 +328,11 @@ struct GeneralSettings: View {
|
||||||
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
||||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
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") {
|
Button("Copy Latest Crash Report") {
|
||||||
copyLatestCrashReport()
|
copyLatestCrashReport()
|
||||||
|
|
@ -666,6 +675,9 @@ struct HUD: View {
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
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: {
|
} header: {
|
||||||
Text("Bluetooth Audio Devices")
|
Text("Bluetooth Audio Devices")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -675,27 +687,30 @@ struct HUD: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
let colorCodingDisabled = progressBarStyle == .segmented
|
||||||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||||||
.disabled(progressBarStyle != .gradient)
|
.disabled(colorCodingDisabled)
|
||||||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
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("Smooth color transitions", key: .useSmoothColorGradient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Color-Coded Progress Bars")
|
Text("Color-Coded Progress Bars")
|
||||||
} footer: {
|
} footer: {
|
||||||
if progressBarStyle != .gradient {
|
if progressBarStyle == .segmented {
|
||||||
Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.")
|
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
} else if Defaults[.useSmoothColorGradient] {
|
} 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)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
} else {
|
} 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)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
@ -744,6 +759,7 @@ struct Media: View {
|
||||||
@Default(.enableSneakPeek) private var enableSneakPeek
|
@Default(.enableSneakPeek) private var enableSneakPeek
|
||||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -784,6 +800,10 @@ struct Media: View {
|
||||||
customBadge(text: "Beta")
|
customBadge(text: "Beta")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||||
|
Text("Replace repeat button with media output control")
|
||||||
|
}
|
||||||
|
.disabled(!showShuffleAndRepeat)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Media controls")
|
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 Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import IOBluetooth
|
import IOBluetooth
|
||||||
|
import IOKit
|
||||||
|
|
||||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||||
class BluetoothAudioManager: ObservableObject {
|
class BluetoothAudioManager: ObservableObject {
|
||||||
|
|
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||||
private var pollingTimer: Timer?
|
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
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
|
|
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
// Update connection state
|
// Update connection state
|
||||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||||
|
|
||||||
|
refreshBatteryLevelsForConnectedDevices()
|
||||||
|
|
||||||
if let lastDevice = connectedDevices.last {
|
if let lastDevice = connectedDevices.last {
|
||||||
lastConnectedDevice = lastDevice
|
lastConnectedDevice = lastDevice
|
||||||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||||||
|
|
@ -201,9 +213,15 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
connectedDevices.append(audioDevice)
|
connectedDevices.append(audioDevice)
|
||||||
lastConnectedDevice = audioDevice
|
lastConnectedDevice = audioDevice
|
||||||
isBluetoothAudioConnected = true
|
isBluetoothAudioConnected = true
|
||||||
|
|
||||||
|
refreshBatteryLevelsForConnectedDevices()
|
||||||
|
|
||||||
// Show HUD for new connection
|
// Show HUD for new connection
|
||||||
showDeviceConnectedHUD(audioDevice)
|
if let refreshedDevice = connectedDevices.last {
|
||||||
|
showDeviceConnectedHUD(refreshedDevice)
|
||||||
|
} else {
|
||||||
|
showDeviceConnectedHUD(audioDevice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||||
|
|
||||||
|
refreshBatteryLevelsForConnectedDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||||||
|
|
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
|
|
||||||
/// Extracts battery level from Bluetooth device
|
/// Extracts battery level from Bluetooth device
|
||||||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||||||
// Try to read battery level from device
|
updateBatteryStatuses()
|
||||||
// Note: This may require additional permissions or may not be available for all devices
|
|
||||||
|
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
|
||||||
// Placeholder: Return nil if not available
|
clearMissingBatteryInfo(for: device)
|
||||||
// TODO: Implement actual battery level reading if device supports it
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,6 +410,590 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
default: return .generic
|
default: return .generic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshBatteryLevelsForConnectedDevices() {
|
||||||
|
updateBatteryStatuses(force: true)
|
||||||
|
|
||||||
|
var updatedDevices: [BluetoothAudioDevice] = []
|
||||||
|
for device in connectedDevices {
|
||||||
|
let refreshedLevel =
|
||||||
|
batteryLevelFromRegistry(forAddress: device.address)
|
||||||
|
?? batteryLevelFromRegistry(forName: device.name)
|
||||||
|
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||||
|
?? batteryLevelFromDefaults(forName: device.name)
|
||||||
|
?? device.batteryLevel
|
||||||
|
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||||||
|
updatedDevices.append(updatedDevice)
|
||||||
|
|
||||||
|
if let refreshedLevel {
|
||||||
|
clearMissingBatteryInfo(forName: device.name, address: device.address)
|
||||||
|
} else {
|
||||||
|
logMissingBatteryInfo(forName: device.name, address: device.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedDevices = updatedDevices
|
||||||
|
if let last = connectedDevices.last {
|
||||||
|
lastConnectedDevice = last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||||
|
guard let address, !address.isEmpty else { return nil }
|
||||||
|
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||||
|
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||||
|
|
||||||
|
let normalizedTarget = normalizeBluetoothIdentifier(address)
|
||||||
|
var bestMatch: Int?
|
||||||
|
|
||||||
|
for (key, value) in deviceCache {
|
||||||
|
guard let payload = value as? [String: Any] else { continue }
|
||||||
|
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
|
||||||
|
if let level = extractBatteryPercentage(from: payload) {
|
||||||
|
let clamped = clampBatteryPercentage(level)
|
||||||
|
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batteryLevelFromDefaults(forName name: String) -> Int? {
|
||||||
|
guard !name.isEmpty else { return nil }
|
||||||
|
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||||
|
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||||
|
|
||||||
|
var bestMatch: Int?
|
||||||
|
|
||||||
|
for value in deviceCache.values {
|
||||||
|
guard let payload = value as? [String: Any] else { continue }
|
||||||
|
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
|
||||||
|
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
|
||||||
|
if let level = extractBatteryPercentage(from: payload) {
|
||||||
|
let clamped = clampBatteryPercentage(level)
|
||||||
|
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batteryLevelFromRegistry(forName name: String) -> Int? {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let normalized = normalizeProductName(trimmed)
|
||||||
|
guard !normalized.isEmpty else { return nil }
|
||||||
|
if let value = batteryStatusByName[normalized] {
|
||||||
|
return clampBatteryPercentage(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateBatteryStatuses(force: Bool = false) {
|
||||||
|
let now = Date()
|
||||||
|
if !force, let lastBatteryStatusUpdate,
|
||||||
|
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedAddressPercentages: [String: Int] = [:]
|
||||||
|
var combinedNamePercentages: [String: Int] = [:]
|
||||||
|
|
||||||
|
let registry = collectRegistryBatteryLevels()
|
||||||
|
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
|
||||||
|
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
|
||||||
|
|
||||||
|
let defaults = collectDefaultsBatteryLevels()
|
||||||
|
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
|
||||||
|
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
|
||||||
|
|
||||||
|
let profiler = collectSystemProfilerBatteryLevels()
|
||||||
|
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
||||||
|
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
||||||
|
|
||||||
|
var statuses: [String: String] = [:]
|
||||||
|
for (key, value) in combinedAddressPercentages {
|
||||||
|
statuses[key] = String(clampBatteryPercentage(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
let applyUpdates = {
|
||||||
|
self.batteryStatus = statuses
|
||||||
|
self.batteryStatusByAddress = combinedAddressPercentages
|
||||||
|
self.batteryStatusByName = combinedNamePercentages
|
||||||
|
self.lastBatteryStatusUpdate = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if Thread.isMainThread {
|
||||||
|
applyUpdates()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync(execute: applyUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
|
||||||
|
guard !source.isEmpty else { return }
|
||||||
|
for (key, value) in source {
|
||||||
|
guard !key.isEmpty else { continue }
|
||||||
|
if let existing = target[key] {
|
||||||
|
target[key] = max(existing, value)
|
||||||
|
} else {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||||
|
var addressPercentages: [String: Int] = [:]
|
||||||
|
var namePercentages: [String: Int] = [:]
|
||||||
|
|
||||||
|
var iterator = io_iterator_t()
|
||||||
|
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
|
||||||
|
|
||||||
|
let servicePort: mach_port_t
|
||||||
|
if #available(macOS 12.0, *) {
|
||||||
|
servicePort = kIOMainPortDefault
|
||||||
|
} else {
|
||||||
|
servicePort = kIOMasterPortDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
|
||||||
|
|
||||||
|
if kernResult == KERN_SUCCESS {
|
||||||
|
var entry: io_object_t = IOIteratorNext(iterator)
|
||||||
|
while entry != 0 {
|
||||||
|
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
|
||||||
|
let normalizedPercent = clampBatteryPercentage(percent)
|
||||||
|
|
||||||
|
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
|
||||||
|
for key in identifierKeys {
|
||||||
|
if let identifier = stringValue(forKey: key, entry: entry) {
|
||||||
|
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
|
||||||
|
if !normalizedIdentifier.isEmpty {
|
||||||
|
if let existing = addressPercentages[normalizedIdentifier] {
|
||||||
|
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
|
||||||
|
} else {
|
||||||
|
addressPercentages[normalizedIdentifier] = normalizedPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nameKeys = [
|
||||||
|
"Product",
|
||||||
|
"ProductName",
|
||||||
|
"DeviceName",
|
||||||
|
"Name",
|
||||||
|
"USB Product Name",
|
||||||
|
"Bluetooth Product Name"
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in nameKeys {
|
||||||
|
if let product = stringValue(forKey: key, entry: entry) {
|
||||||
|
let normalizedName = normalizeProductName(product)
|
||||||
|
if !normalizedName.isEmpty {
|
||||||
|
if let existing = namePercentages[normalizedName] {
|
||||||
|
namePercentages[normalizedName] = max(existing, normalizedPercent)
|
||||||
|
} else {
|
||||||
|
namePercentages[normalizedName] = normalizedPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IOObjectRelease(entry)
|
||||||
|
entry = IOIteratorNext(iterator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IOObjectRelease(iterator)
|
||||||
|
|
||||||
|
return (addressPercentages, namePercentages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||||
|
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||||
|
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
|
||||||
|
return ([:], [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
var addressPercentages: [String: Int] = [:]
|
||||||
|
var namePercentages: [String: Int] = [:]
|
||||||
|
|
||||||
|
for (key, value) in deviceCache {
|
||||||
|
guard let payload = value as? [String: Any] else { continue }
|
||||||
|
guard let level = extractBatteryPercentage(from: payload) else { continue }
|
||||||
|
let clamped = clampBatteryPercentage(level)
|
||||||
|
|
||||||
|
let normalizedKey = normalizeBluetoothIdentifier(key)
|
||||||
|
if !normalizedKey.isEmpty {
|
||||||
|
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
for identifier in identifiersFromDeviceCachePayload(payload) {
|
||||||
|
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
|
||||||
|
let normalizedName = normalizeProductName(name)
|
||||||
|
if !normalizedName.isEmpty {
|
||||||
|
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (addressPercentages, namePercentages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||||
|
guard let root = systemProfilerBluetoothDictionary() else {
|
||||||
|
return ([:], [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
var addressPercentages: [String: Int] = [:]
|
||||||
|
var namePercentages: [String: Int] = [:]
|
||||||
|
|
||||||
|
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
|
||||||
|
for deviceGroup in connectedList {
|
||||||
|
for (rawName, payload) in deviceGroup {
|
||||||
|
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
|
||||||
|
let clamped = clampBatteryPercentage(percent)
|
||||||
|
|
||||||
|
let normalizedName = normalizeProductName(rawName)
|
||||||
|
if !normalizedName.isEmpty {
|
||||||
|
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
for address in profilerAddressCandidates(from: payload) {
|
||||||
|
let normalizedAddress = normalizeBluetoothIdentifier(address)
|
||||||
|
if !normalizedAddress.isEmpty {
|
||||||
|
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (addressPercentages, namePercentages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
||||||
|
var identifiers: Set<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
|
// MARK: - HUD Display
|
||||||
|
|
||||||
|
|
@ -382,11 +1004,19 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||||
|
|
||||||
// Convert battery percentage to 0.0-1.0 value
|
// Convert battery percentage to 0.0-1.0 value
|
||||||
let batteryValue: CGFloat = if let battery = device.batteryLevel {
|
let reportedBattery = device.batteryLevel
|
||||||
CGFloat(battery) / 100.0
|
?? 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 {
|
} else {
|
||||||
0.0 // 0 means battery info not available
|
0.0 // 0 means battery info not available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
||||||
|
|
||||||
// Show HUD via coordinator
|
// Show HUD via coordinator
|
||||||
coordinator.toggleSneakPeek(
|
coordinator.toggleSneakPeek(
|
||||||
|
|
@ -411,16 +1041,47 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
cancellables.removeAll()
|
cancellables.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func refreshConnectedDeviceBatteries() {
|
||||||
|
refreshBatteryLevelsForConnectedDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Models
|
// MARK: - Models
|
||||||
|
|
||||||
struct BluetoothAudioDevice: Identifiable {
|
struct BluetoothAudioDevice: Identifiable {
|
||||||
let id = UUID()
|
let id: UUID
|
||||||
let name: String
|
let name: String
|
||||||
let address: String
|
let address: String
|
||||||
let batteryLevel: Int? // 0-100, nil if not available
|
let batteryLevel: Int? // 0-100, nil if not available
|
||||||
let deviceType: BluetoothAudioDeviceType
|
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 {
|
enum BluetoothAudioDeviceType {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
import SkyLightWindow
|
import SkyLightWindow
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class LockScreenPanelManager {
|
class LockScreenPanelManager {
|
||||||
|
|
@ -89,18 +90,24 @@ class LockScreenPanelManager {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
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 {
|
guard let window = panelWindow, let baseFrame = collapsedFrame else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||||
let originX = baseFrame.midX - (targetSize.width / 2)
|
let targetWidth = baseSize.width
|
||||||
|
let targetHeight = baseSize.height + additionalHeight
|
||||||
|
let originX = baseFrame.midX - (targetWidth / 2)
|
||||||
let originY = baseFrame.origin.y
|
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 {
|
if animated {
|
||||||
window.animator().setFrame(targetFrame, display: true)
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.45
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
window.animator().setFrame(targetFrame, display: true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Defaults
|
import Defaults
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LockScreenWeatherManager: ObservableObject {
|
final class LockScreenWeatherManager: ObservableObject {
|
||||||
|
|
@ -11,8 +12,12 @@ final class LockScreenWeatherManager: ObservableObject {
|
||||||
private let provider = LockScreenWeatherProvider()
|
private let provider = LockScreenWeatherProvider()
|
||||||
private let locationProvider = LockScreenWeatherLocationProvider()
|
private let locationProvider = LockScreenWeatherLocationProvider()
|
||||||
private var lastFetchDate: Date?
|
private var lastFetchDate: Date?
|
||||||
|
private var latestWeatherPayload: LockScreenWeatherSnapshot?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
observeAccessoryChanges()
|
||||||
|
}
|
||||||
|
|
||||||
func prepareLocationAccess() {
|
func prepareLocationAccess() {
|
||||||
locationProvider.prepareAuthorization()
|
locationProvider.prepareAuthorization()
|
||||||
|
|
@ -28,9 +33,15 @@ final class LockScreenWeatherManager: ObservableObject {
|
||||||
|
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refresh(force: self.snapshot == nil)
|
let snapshot = await self.refresh(force: self.snapshot == nil)
|
||||||
if let snapshot = self.snapshot {
|
|
||||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
guard LockScreenManager.shared.currentLockStatus else {
|
||||||
|
LockScreenWeatherPanelManager.shared.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let snapshot {
|
||||||
|
self.deliver(snapshot, forceShow: true)
|
||||||
} else {
|
} else {
|
||||||
LockScreenWeatherPanelManager.shared.hide()
|
LockScreenWeatherPanelManager.shared.hide()
|
||||||
}
|
}
|
||||||
|
|
@ -41,32 +52,222 @@ final class LockScreenWeatherManager: ObservableObject {
|
||||||
LockScreenWeatherPanelManager.shared.hide()
|
LockScreenWeatherPanelManager.shared.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh(force: Bool = false) async {
|
@discardableResult
|
||||||
|
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
|
||||||
let interval = Defaults[.lockScreenWeatherRefreshInterval]
|
let interval = Defaults[.lockScreenWeatherRefreshInterval]
|
||||||
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
|
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
|
||||||
if let snapshot = snapshot {
|
if let payload = latestWeatherPayload {
|
||||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||||
|
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||||
|
}
|
||||||
|
let snapshot = makeSnapshot(from: payload)
|
||||||
|
self.snapshot = snapshot
|
||||||
|
deliver(snapshot, forceShow: false)
|
||||||
|
return snapshot
|
||||||
|
} else if let snapshot = snapshot {
|
||||||
|
deliver(snapshot, forceShow: false)
|
||||||
|
return snapshot
|
||||||
}
|
}
|
||||||
return
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let location = await locationProvider.currentLocation()
|
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
|
self.snapshot = snapshot
|
||||||
lastFetchDate = Date()
|
lastFetchDate = Date()
|
||||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
deliver(snapshot, forceShow: false)
|
||||||
|
return snapshot
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
|
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 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 temperatureText: String
|
||||||
let symbolName: String
|
let symbolName: String
|
||||||
let description: String
|
let description: String
|
||||||
let locationName: String?
|
let locationName: String?
|
||||||
|
let charging: ChargingInfo?
|
||||||
|
let bluetooth: BluetoothInfo?
|
||||||
|
let showsLocation: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor LockScreenWeatherProvider {
|
private actor LockScreenWeatherProvider {
|
||||||
|
|
@ -122,7 +323,10 @@ private actor LockScreenWeatherProvider {
|
||||||
temperatureText: temperatureText,
|
temperatureText: temperatureText,
|
||||||
symbolName: symbol,
|
symbolName: symbol,
|
||||||
description: description,
|
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
|
// MARK: - HUD Dispatch
|
||||||
|
|
||||||
private func sendVolumeNotification(value: Float) {
|
private func sendVolumeNotification(value: Float) {
|
||||||
|
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
||||||
|
return
|
||||||
|
}
|
||||||
coordinator?.toggleSneakPeek(
|
coordinator?.toggleSneakPeek(
|
||||||
status: true,
|
status: true,
|
||||||
type: .volume,
|
type: .volume,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,38 @@ extension Notification.Name {
|
||||||
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
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 {
|
final class SystemVolumeController {
|
||||||
static let shared = SystemVolumeController()
|
static let shared = SystemVolumeController()
|
||||||
|
|
||||||
|
|
@ -46,6 +78,10 @@ final class SystemVolumeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func adjust(by delta: Float) {
|
func adjust(by delta: Float) {
|
||||||
|
guard delta != 0 else { return }
|
||||||
|
if isMuted {
|
||||||
|
setMuted(false)
|
||||||
|
}
|
||||||
var newValue = currentVolume + delta
|
var newValue = currentVolume + delta
|
||||||
newValue = max(0, min(1, newValue))
|
newValue = max(0, min(1, newValue))
|
||||||
setVolume(newValue)
|
setVolume(newValue)
|
||||||
|
|
@ -64,18 +100,53 @@ final class SystemVolumeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVolume(_ value: Float) {
|
func setVolume(_ value: Float) {
|
||||||
var volume = max(0, min(1, value))
|
let clamped = max(0, min(1, value))
|
||||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
if isMuted && clamped > 0 {
|
||||||
if status != noErr {
|
setMuted(false)
|
||||||
NSLog("⚠️ Failed to set volume: \(status)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func setMuted(_ muted: Bool) {
|
||||||
var muteFlag: UInt32 = muted ? 1 : 0
|
var muteFlag: UInt32 = muted ? 1 : 0
|
||||||
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
let elements = muteElements()
|
||||||
if status != noErr {
|
|
||||||
NSLog("⚠️ Failed to set mute state: \(status)")
|
if elements.isEmpty {
|
||||||
|
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Failed to set mute state: \(status)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for element in elements {
|
||||||
|
var value = muteFlag
|
||||||
|
let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Failed to set mute state for element \(element): \(status)")
|
||||||
|
} else {
|
||||||
|
cache(element: element, for: kAudioDevicePropertyMute)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,22 +230,85 @@ final class SystemVolumeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getVolume() -> Float {
|
private func getVolume() -> Float {
|
||||||
var volume = Float32(0)
|
let elements = volumeElements()
|
||||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
|
||||||
if status != noErr {
|
if elements.isEmpty {
|
||||||
NSLog("⚠️ Unable to fetch volume: \(status)")
|
var volume = Float32(0)
|
||||||
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Unable to fetch volume: \(status)")
|
||||||
|
}
|
||||||
|
return volume
|
||||||
}
|
}
|
||||||
return volume
|
|
||||||
|
var masterVolume: Float?
|
||||||
|
var accumulator: Float = 0
|
||||||
|
var count: Float = 0
|
||||||
|
|
||||||
|
for element in elements {
|
||||||
|
var value = Float32(0)
|
||||||
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value)
|
||||||
|
if status == noErr {
|
||||||
|
if element == kAudioObjectPropertyElementMaster {
|
||||||
|
masterVolume = value
|
||||||
|
}
|
||||||
|
accumulator += value
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let masterVolume {
|
||||||
|
return masterVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return accumulator / count
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallback = Float32(0)
|
||||||
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback)
|
||||||
|
if status != noErr {
|
||||||
|
NSLog("⚠️ Unable to fetch fallback volume: \(status)")
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getMuteState() -> Bool {
|
private func getMuteState() -> Bool {
|
||||||
var mute: UInt32 = 0
|
let elements = muteElements()
|
||||||
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
|
||||||
|
if elements.isEmpty {
|
||||||
|
var mute: UInt32 = 0
|
||||||
|
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
||||||
|
if status != noErr {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mute != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var retrieved = false
|
||||||
|
var allMuted = true
|
||||||
|
|
||||||
|
for element in elements {
|
||||||
|
var value: UInt32 = 0
|
||||||
|
let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||||
|
if status == noErr {
|
||||||
|
retrieved = true
|
||||||
|
if value == 0 {
|
||||||
|
allMuted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved {
|
||||||
|
return allMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallback: UInt32 = 0
|
||||||
|
let status = getData(selector: kAudioDevicePropertyMute, data: &fallback)
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
// Some devices do not support mute; treat as not muted
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return mute != 0
|
return fallback != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshPropertyElements() {
|
private func refreshPropertyElements() {
|
||||||
|
|
@ -264,6 +398,38 @@ final class SystemVolumeController {
|
||||||
}
|
}
|
||||||
return lastStatus
|
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 {
|
final class SystemBrightnessController {
|
||||||
|
|
@ -275,9 +441,17 @@ final class SystemBrightnessController {
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var notificationsInstalled = false
|
private var notificationsInstalled = false
|
||||||
private var displayID: CGDirectDisplayID = CGMainDisplayID()
|
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() {
|
private init() {
|
||||||
registerExternalNotifications()
|
registerExternalNotifications()
|
||||||
|
lastEmittedBrightness = currentBrightness
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
|
|
@ -286,6 +460,8 @@ final class SystemBrightnessController {
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
onBrightnessChange = nil
|
onBrightnessChange = nil
|
||||||
|
brightnessAnimationTimer?.invalidate()
|
||||||
|
brightnessAnimationTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func adjust(by delta: Float) {
|
func adjust(by delta: Float) {
|
||||||
|
|
@ -294,17 +470,8 @@ final class SystemBrightnessController {
|
||||||
|
|
||||||
func setBrightness(_ value: Float) {
|
func setBrightness(_ value: Float) {
|
||||||
let clamped = max(0, min(1, value))
|
let clamped = max(0, min(1, value))
|
||||||
if setBrightnessViaDisplayServices(clamped) {
|
DispatchQueue.main.async {
|
||||||
notifyCurrentBrightness()
|
self.beginBrightnessAnimation(to: clamped)
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,10 +491,77 @@ final class SystemBrightnessController {
|
||||||
|
|
||||||
private func notifyCurrentBrightness() {
|
private func notifyCurrentBrightness() {
|
||||||
let brightness = currentBrightness
|
let brightness = currentBrightness
|
||||||
DispatchQueue.main.async {
|
emitBrightnessChange(value: brightness)
|
||||||
self.onBrightnessChange?(brightness)
|
}
|
||||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["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? {
|
private func displayService() -> io_service_t? {
|
||||||
|
|
@ -386,6 +620,7 @@ final class SystemBrightnessController {
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
brightnessAnimationTimer?.invalidate()
|
||||||
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,10 +334,14 @@ extension Defaults.Keys {
|
||||||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
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)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
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 lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
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 enableGradient = Key<Bool>("enableGradient", default: false)
|
||||||
static let systemEventIndicatorShadow = Key<Bool>("systemEventIndicatorShadow", default: false)
|
static let systemEventIndicatorShadow = Key<Bool>("systemEventIndicatorShadow", default: false)
|
||||||
static let systemEventIndicatorUseAccent = Key<Bool>("systemEventIndicatorUseAccent", default: false)
|
static let systemEventIndicatorUseAccent = Key<Bool>("systemEventIndicatorUseAccent", default: false)
|
||||||
|
static let showProgressPercentages = Key<Bool>("showProgressPercentages", default: false)
|
||||||
|
|
||||||
// MARK: Shelf
|
// MARK: Shelf
|
||||||
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
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 useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
|
||||||
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
|
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
|
||||||
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
|
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
|
// MARK: Stats Feature
|
||||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var isClipboardPopoverActive: Bool = false
|
@Published var isClipboardPopoverActive: Bool = false
|
||||||
@Published var isColorPickerPopoverActive: Bool = false
|
@Published var isColorPickerPopoverActive: Bool = false
|
||||||
@Published var isStatsPopoverActive: Bool = false
|
@Published var isStatsPopoverActive: Bool = false
|
||||||
|
@Published var isMediaOutputPopoverActive: Bool = false
|
||||||
@Published var shouldRecheckHover: Bool = false
|
@Published var shouldRecheckHover: Bool = false
|
||||||
|
|
||||||
let webcamManager = WebcamManager.shared
|
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">
|
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="120">
|
||||||
</p>
|
</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).
|
> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||||
|
|
||||||
# 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">
|
<img src=".github/assets/atoll-clipboard.png" alt="Clipboard manager" width="320">
|
||||||
</p>
|
</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
|
### 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.
|
- **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.
|
- **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