mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
2eb3185978
28 changed files with 2890 additions and 371 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
BIN
.github/assets/atoll-demo.gif
vendored
BIN
.github/assets/atoll-demo.gif
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 133 B |
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
|
||||
71
CODE_OF_CONDUCT.md
Normal file
71
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others’ private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [maintainer email or Discord]. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
89
CONTRIBUTING.md
Normal file
89
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<p align="center">
|
||||
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="100">
|
||||
</p>
|
||||
|
||||
# Contributing to Atoll
|
||||
|
||||
Thank you for your interest in contributing to Atoll! We welcome contributions from everyone—developers, designers, testers, and documentation writers. Please read the following guidelines to help us maintain a collaborative and high-quality project.
|
||||
|
||||
## Table of Contents
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Design Contributions](#design-contributions)
|
||||
- [Documentation](#documentation)
|
||||
- [Community & Support](#community--support)
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork the repository** and clone your fork locally.
|
||||
2. **Create a feature branch** for your changes: `git switch -c feature/your-feature-name`
|
||||
3. **Make your changes** following the guidelines below.
|
||||
4. **Test your changes** to ensure they work as expected and do not break existing functionality.
|
||||
5. **Commit** with clear, descriptive messages.
|
||||
6. **Push** to your fork and submit a **pull request** (PR) to the `main` branch.
|
||||
7. **Participate in code review** and address any feedback.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We are committed to fostering a welcoming and inclusive environment. Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.
|
||||
|
||||
## Development Setup
|
||||
|
||||
- **Requirements:**
|
||||
- macOS Sonoma 14.0 or later
|
||||
- Xcode 15.0+ with Swift 5.9 toolchain
|
||||
- MacBook with a notch (for full feature testing)
|
||||
- **Clone the repo:**
|
||||
```bash
|
||||
git clone https://github.com/Ebullioscopic/Atoll.git
|
||||
cd Atoll
|
||||
open DynamicIsland.xcodeproj
|
||||
```
|
||||
- **Build & Run:**
|
||||
- Select your Mac as the run destination in Xcode.
|
||||
- Build and run (Cmd+R).
|
||||
- Grant any requested permissions.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
- Ensure your branch is up to date with `main` before submitting a PR.
|
||||
- Provide a clear description of your changes and the motivation behind them.
|
||||
- Reference any related issues or discussions.
|
||||
- Add screenshots or screen recordings for UI changes.
|
||||
- Ensure the code builds without errors or warnings.
|
||||
- Respond promptly to review feedback.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
- Follow Swift API Design Guidelines.
|
||||
- Use meaningful variable and function names.
|
||||
- Keep functions focused and concise.
|
||||
- Add inline documentation for public APIs and complex logic.
|
||||
- Write unit tests for new features where practical.
|
||||
- Avoid introducing unnecessary dependencies.
|
||||
|
||||
## Design Contributions
|
||||
|
||||
- Submit UI/UX improvements, mockups, or visual assets.
|
||||
- Maintain consistency with macOS Human Interface Guidelines.
|
||||
- Provide rationale for design decisions.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Improve user guides, API docs, and troubleshooting sections.
|
||||
- Translate documentation into other languages.
|
||||
- Clarify setup instructions and add usage examples.
|
||||
|
||||
## Community & Support
|
||||
|
||||
- Join our [Discord community](https://discord.gg/zvjB4tJBkt) for discussion, support, and collaboration.
|
||||
- Be respectful and constructive in all interactions.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make Atoll better!
|
||||
|
|
@ -401,6 +401,8 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
|
|
@ -412,7 +414,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.1-beta";
|
||||
MARKETING_VERSION = "1.0.2-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -449,6 +451,8 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
|
|
@ -460,7 +464,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.1-beta";
|
||||
MARKETING_VERSION = "1.0.2-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -200,14 +200,14 @@ struct ContentView: View {
|
|||
}
|
||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ struct ContentView: View {
|
|||
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
||||
// Recheck hover state when popovers are closed
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering {
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -320,10 +320,9 @@ struct ContentView: View {
|
|||
}
|
||||
.sensoryFeedback(.alignment, trigger: haptics)
|
||||
.contextMenu {
|
||||
SettingsLink(label: {
|
||||
Text("Settings")
|
||||
})
|
||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
||||
Button("Settings") {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
}
|
||||
// Button("Edit") { // Doesnt work....
|
||||
// let dn = DynamicNotch(content: EditPanelView())
|
||||
// dn.toggle()
|
||||
|
|
@ -404,7 +403,7 @@ struct ContentView: View {
|
|||
.frame(width: 76, alignment: .trailing)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) {
|
||||
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
|
||||
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||
.transition(.opacity)
|
||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
||||
|
|
@ -429,7 +428,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
if coordinator.sneakPeek.show {
|
||||
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] {
|
||||
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
|
||||
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
|
||||
//
|
||||
})
|
||||
|
|
@ -754,7 +753,8 @@ struct ContentView: View {
|
|||
return vm.isBatteryPopoverActive ||
|
||||
vm.isClipboardPopoverActive ||
|
||||
vm.isColorPickerPopoverActive ||
|
||||
vm.isStatsPopoverActive
|
||||
vm.isStatsPopoverActive ||
|
||||
vm.isMediaOutputPopoverActive
|
||||
}
|
||||
|
||||
// Helper to prevent rapid haptic feedback
|
||||
|
|
|
|||
|
|
@ -70,6 +70,15 @@ struct DynamicNotchApp: App {
|
|||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
|
||||
var commands: some Commands {
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
|
@ -405,6 +414,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
self?.debouncedUpdateWindowSize()
|
||||
}.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableShortcuts, options: []).sink { change in
|
||||
KeyboardShortcuts.isEnabled = change.newValue
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe minimalistic UI setting changes - trigger window resize
|
||||
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
|
||||
// Force sneak peek to standard mode when minimalistic UI is enabled
|
||||
|
|
@ -477,10 +490,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
self, selector: #selector(onScreenUnlocked(_:)),
|
||||
name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil)
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
self.coordinator.toggleSneakPeek(
|
||||
|
|
@ -490,10 +501,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
|
|
@ -529,112 +538,72 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only start timer if the timer feature is enabled
|
||||
guard Defaults[.enableTimerFeature] else { return }
|
||||
|
||||
// Start a 5-minute demo timer
|
||||
TimerManager.shared.startDemoTimer(duration: 300)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open clipboard if the feature is enabled
|
||||
guard Defaults[.enableClipboardManager] else { return }
|
||||
|
||||
// Start clipboard monitoring if not already running
|
||||
if !ClipboardManager.shared.isMonitoring {
|
||||
ClipboardManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Handle keyboard shortcut based on display mode
|
||||
switch Defaults[.clipboardDisplayMode] {
|
||||
case .panel:
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
// For popover mode, first ensure notch is open, then toggle popover
|
||||
|
||||
// If notch is closed, open it first
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
// Wait a moment for the notch to open, then show popover
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
} else {
|
||||
// Notch is already open, toggle popover immediately
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open color picker panel if the feature is enabled
|
||||
guard Defaults[.enableColorPickerFeature] else { return }
|
||||
|
||||
// Toggle color picker panel
|
||||
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open screen assistant if the feature is enabled
|
||||
guard Defaults[.enableScreenAssistant] else { return }
|
||||
|
||||
// Handle keyboard shortcut based on display mode
|
||||
switch Defaults[.screenAssistantDisplayMode] {
|
||||
case .panel:
|
||||
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
|
||||
case .popover:
|
||||
// For popover mode, first ensure notch is open, then toggle popover
|
||||
|
||||
// If notch is closed, open it first
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
// Wait a moment for the notch to open, then show popover
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||
}
|
||||
} else {
|
||||
// Notch is already open, toggle popover immediately
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .statsPanel) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open stats panel if both stats feature and panel are enabled
|
||||
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
|
||||
|
||||
// Start stats monitoring if not already running
|
||||
if !StatsManager.shared.isMonitoring {
|
||||
StatsManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Toggle stats panel
|
||||
StatsPanelManager.shared.toggleStatsPanel()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3489,6 +3489,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Battery charging" : {
|
||||
"comment" : "Battery charging without estimate"
|
||||
},
|
||||
"Battery charging, %@ remaining" : {
|
||||
"comment" : "Charging time remaining"
|
||||
},
|
||||
"Battery fully charged" : {
|
||||
"comment" : "Battery is full"
|
||||
},
|
||||
"Battery Information" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -3811,6 +3820,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Bluetooth device %@ at %d percent" : {
|
||||
"comment" : "Bluetooth device battery",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Bluetooth device %1$@ at %2$d percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Boost your productivity with Clipboard Manager" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -5262,8 +5282,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature." : {
|
||||
|
||||
"Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options." : {
|
||||
"comment" : "An explanatory text that appears when the user tries to enable color-coded color gradients in Segmented progress bars. It instructs the user to switch to Hierarchical or Gradient progress bars to enable these features.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Color-Coded Progress Bars" : {
|
||||
"comment" : "A section header that lists the options for color-coded progress bars.",
|
||||
|
|
@ -6624,8 +6645,8 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)" : {
|
||||
"comment" : "A description of the \"Discrete\" color-coded progress bar style, including the colors used for battery and volume levels.",
|
||||
"Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
|
||||
"comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Disk Read" : {
|
||||
|
|
@ -11545,6 +11566,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Media output" : {
|
||||
"comment" : "Accessibility label describing the control that opens the media output selector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Media playback live activity" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -11957,6 +11982,7 @@
|
|||
}
|
||||
},
|
||||
"Mic %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -12056,6 +12082,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Mic muted" : {
|
||||
"comment" : "A description of the state when the microphone is muted.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mic unmuted" : {
|
||||
"comment" : "A description of the state when the microphone is unmuted.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Microphone" : {
|
||||
"comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -13088,6 +13122,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"No audio outputs available" : {
|
||||
"comment" : "Displayed when no audio output devices can be listed in the media output selector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No clipboard history" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -14338,6 +14376,14 @@
|
|||
},
|
||||
"Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : {
|
||||
|
||||
},
|
||||
"Output devices" : {
|
||||
"comment" : "Section title for the list of available audio output devices in the media output selector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Output volume" : {
|
||||
"comment" : "Label describing the system volume control inside the media output selector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
||||
|
||||
|
|
@ -15170,6 +15216,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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." : {
|
||||
|
||||
},
|
||||
|
|
@ -16079,6 +16129,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Settings…" : {
|
||||
"comment" : "A command that shows the settings window.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Shelf" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -16954,8 +17008,8 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)" : {
|
||||
"comment" : "A detailed description of the \"Smooth\" color-coded gradient display feature.",
|
||||
"Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." : {
|
||||
"comment" : "A description of how the color-coding feature works.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,31 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
enum ColorCodingMode {
|
||||
case volume
|
||||
case battery
|
||||
|
||||
func intensity(for value: CGFloat) -> CGFloat {
|
||||
let clamped = min(max(value, 0), 1)
|
||||
switch self {
|
||||
case .volume:
|
||||
return clamped
|
||||
case .battery:
|
||||
return 1 - clamped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorCodedProgressBar: View {
|
||||
let value: CGFloat // 0.0 to 1.0
|
||||
let reversed: Bool // If true: green at low, red at high (for volume)
|
||||
let mode: ColorCodingMode
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
|
||||
|
||||
init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
|
||||
init(value: CGFloat, mode: ColorCodingMode = .battery, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
|
||||
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
|
||||
self.reversed = reversed
|
||||
self.mode = mode
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.smoothGradient = smoothGradient
|
||||
|
|
@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View {
|
|||
|
||||
// Filled track with color gradient or solid color
|
||||
RoundedRectangle(cornerRadius: height / 2)
|
||||
.fill(smoothGradient ?
|
||||
AnyShapeStyle(LinearGradient(
|
||||
gradient: gradientForValue,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)) :
|
||||
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
|
||||
)
|
||||
.fill(fillStyle)
|
||||
.frame(width: width * value, height: height)
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
|
||||
/// Generates a gradient based on the current value
|
||||
private var gradientForValue: Gradient {
|
||||
let color = colorForPercentage(value, reversed: reversed)
|
||||
|
||||
// Create a gradient that transitions smoothly
|
||||
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
|
||||
let endColor = color
|
||||
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
|
||||
/// Calculates discrete color for a given percentage (no smooth transitions)
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Discrete color (red, yellow, or green only)
|
||||
private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.33:
|
||||
// Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
case 0.33..<0.67:
|
||||
// Yellow zone (medium)
|
||||
return Color(red: 1.0, green: 0.8, blue: 0.0)
|
||||
default:
|
||||
// Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates color for a given percentage with smooth transitions
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Color with smooth gradient transitions
|
||||
private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.2:
|
||||
// Pure Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
|
||||
case 0.2..<0.35:
|
||||
// Red → Orange transition
|
||||
let t = (normalized - 0.2) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.35..<0.5:
|
||||
// Orange → Yellow transition
|
||||
let t = (normalized - 0.35) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.5..<0.65:
|
||||
// Yellow → Yellow-Green transition
|
||||
let t = (normalized - 0.5) / 0.15
|
||||
let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
|
||||
let green: CGFloat = 1.0
|
||||
let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.65..<0.8:
|
||||
// Yellow-Green → Green transition
|
||||
let t = (normalized - 0.65) / 0.15
|
||||
let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
|
||||
let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
|
||||
let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
default:
|
||||
// Pure Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
private var fillStyle: AnyShapeStyle {
|
||||
let intensity = mode.intensity(for: value)
|
||||
if smoothGradient {
|
||||
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
|
||||
} else {
|
||||
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View {
|
|||
extension ColorCodedProgressBar {
|
||||
/// Creates a battery-style progress bar (green at high, red at low)
|
||||
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
|
||||
ColorCodedProgressBar(value: value, mode: .battery, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
|
||||
/// Creates a volume-style progress bar (red at high, green at low)
|
||||
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
|
||||
ColorCodedProgressBar(value: value, mode: .volume, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
|
||||
/// Shared helper for other progress bar implementations that need the same palette.
|
||||
static func shapeStyle(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> AnyShapeStyle {
|
||||
let intensity = mode.intensity(for: min(max(value, 0), 1))
|
||||
if smoothGradient {
|
||||
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
|
||||
}
|
||||
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
|
||||
}
|
||||
|
||||
/// Returns the palette color that matches the configured thresholds. Useful for non-linear indicators.
|
||||
static func paletteColor(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> Color {
|
||||
let normalized = min(max(value, 0), 1)
|
||||
let intensity = mode.intensity(for: normalized)
|
||||
return ColorCodedPalette.color(for: intensity, smooth: smoothGradient)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
private enum ColorCodedPalette {
|
||||
private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
|
||||
private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
|
||||
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
|
||||
|
||||
static func color(for intensity: CGFloat, smooth: Bool) -> Color {
|
||||
if smooth {
|
||||
return smoothColor(for: intensity)
|
||||
}
|
||||
return discreteColor(for: intensity)
|
||||
}
|
||||
|
||||
static func discreteColor(for intensity: CGFloat) -> Color {
|
||||
let clamped = min(max(intensity, 0), 1)
|
||||
switch clamped {
|
||||
case ..<0.6:
|
||||
return color(from: greenComponents)
|
||||
case ..<0.85:
|
||||
return color(from: yellowComponents)
|
||||
default:
|
||||
return color(from: redComponents)
|
||||
}
|
||||
}
|
||||
|
||||
static func gradient(for intensity: CGFloat) -> LinearGradient {
|
||||
let endColor = smoothColor(for: intensity)
|
||||
let startColor = smoothColor(for: max(intensity - 0.15, 0))
|
||||
return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
|
||||
private static func smoothColor(for intensity: CGFloat) -> Color {
|
||||
let clamped = min(max(intensity, 0), 1)
|
||||
switch clamped {
|
||||
case ..<0.6:
|
||||
// Stay green within the comfort zone
|
||||
return color(from: greenComponents)
|
||||
case ..<0.85:
|
||||
let t = (clamped - 0.6) / 0.25
|
||||
return blend(from: greenComponents, to: yellowComponents, fraction: t)
|
||||
default:
|
||||
let t = (clamped - 0.85) / 0.15
|
||||
return blend(from: yellowComponents, to: redComponents, fraction: t)
|
||||
}
|
||||
}
|
||||
|
||||
private static func blend(from start: (CGFloat, CGFloat, CGFloat), to end: (CGFloat, CGFloat, CGFloat), fraction: CGFloat) -> Color {
|
||||
let t = min(max(fraction, 0), 1)
|
||||
let red = lerp(start.0, end.0, t)
|
||||
let green = lerp(start.1, end.1, t)
|
||||
let blue = lerp(start.2, end.2, t)
|
||||
return color(from: (red, green, blue))
|
||||
}
|
||||
|
||||
private static func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
private static func color(from components: (CGFloat, CGFloat, CGFloat)) -> Color {
|
||||
Color(red: Double(components.0), green: Double(components.1), blue: Double(components.2))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,42 @@ struct InlineHUD: View {
|
|||
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
|
||||
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
@Default(.showProgressPercentages) var showProgressPercentages
|
||||
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
|
||||
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
|
||||
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
|
||||
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
@State private var displayName: String = ""
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
let useCircularIndicator = useCircularBluetoothBatteryIndicator
|
||||
let hasBatteryLevel = value > 0
|
||||
|
||||
let baseInfoWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
return showBluetoothDeviceNameMarquee ? 100 : 52
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
let baseTrailingWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? 100 : 70
|
||||
}
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? 100 : 70
|
||||
} else {
|
||||
return showBluetoothBatteryPercentageText ? 112 : 82
|
||||
}
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
|
||||
return HStack {
|
||||
HStack(spacing: 5) {
|
||||
Group {
|
||||
switch (type) {
|
||||
|
|
@ -77,22 +107,15 @@ struct InlineHUD: View {
|
|||
|
||||
// Use marquee text for device names to handle long names
|
||||
if type == .bluetoothAudio {
|
||||
if showBluetoothDeviceNameMarquee {
|
||||
MarqueeText(
|
||||
$displayName,
|
||||
font: .system(size: 13, weight: .medium),
|
||||
nsFont: .body,
|
||||
textColor: .white,
|
||||
minDuration: 0.5,
|
||||
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
minDuration: 0.2,
|
||||
frameWidth: infoWidth
|
||||
)
|
||||
.onAppear {
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: type) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
} else {
|
||||
Text(Type2Name(type))
|
||||
|
|
@ -103,7 +126,7 @@ struct InlineHUD: View {
|
|||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
.frame(width: infoWidth, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
|
|
@ -127,45 +150,40 @@ struct InlineHUD: View {
|
|||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.contentTransition(.interpolate)
|
||||
} else if (type == .bluetoothAudio) {
|
||||
// Bluetooth device battery display
|
||||
HStack(spacing: 4) {
|
||||
if value > 0 {
|
||||
// Show color-coded battery bar if battery info available
|
||||
// Color-coding disabled for segmented or hierarchical mode
|
||||
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
|
||||
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
if hasBatteryLevel {
|
||||
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
||||
if useCircularIndicator {
|
||||
CircularBatteryIndicator(
|
||||
value: value,
|
||||
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||
smoothGradient: useSmoothColorGradient
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
DraggableProgressBar(value: .constant(value))
|
||||
.frame(width: 60)
|
||||
LinearBatteryIndicator(
|
||||
value: value,
|
||||
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||
smoothGradient: useSmoothColorGradient
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if showBluetoothBatteryPercentageText {
|
||||
Text("\(Int(value * 100))%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
// No battery info available
|
||||
Text("Connected")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
} else {
|
||||
// Volume and brightness displays
|
||||
HStack {
|
||||
// Color-coding only available in gradient mode, not hierarchical or segmented
|
||||
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
|
||||
// Color-coded volume (reversed: red at high, green at low)
|
||||
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
} else {
|
||||
// Default draggable progress bar
|
||||
DraggableProgressBar(value: $value)
|
||||
}
|
||||
|
||||
if (type == .volume && value.isZero) {
|
||||
Group {
|
||||
if type == .volume {
|
||||
Group {
|
||||
if value.isZero {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
|
|
@ -173,14 +191,104 @@ struct InlineHUD: View {
|
|||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
DraggableProgressBar(value: $value, colorMode: .volume)
|
||||
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.animation(.smooth(duration: 0.2), value: value.isZero)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
DraggableProgressBar(value: $value)
|
||||
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
}
|
||||
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
|
||||
.onAppear {
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: type) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularBatteryIndicator: View {
|
||||
let value: CGFloat
|
||||
let useColorCoding: Bool
|
||||
let smoothGradient: Bool
|
||||
|
||||
private var clampedValue: CGFloat {
|
||||
min(max(value, 0), 1)
|
||||
}
|
||||
|
||||
private var indicatorColor: Color {
|
||||
if useColorCoding {
|
||||
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
||||
}
|
||||
return .white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 2.6)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: max(clampedValue, 0.015))
|
||||
.rotation(.degrees(-90))
|
||||
.stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round))
|
||||
}
|
||||
.frame(width: 22, height: 22)
|
||||
.animation(.smooth(duration: 0.18), value: clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LinearBatteryIndicator: View {
|
||||
let value: CGFloat
|
||||
let useColorCoding: Bool
|
||||
let smoothGradient: Bool
|
||||
|
||||
private let trackWidth: CGFloat = 54
|
||||
private let trackHeight: CGFloat = 6
|
||||
|
||||
private var clampedValue: CGFloat {
|
||||
min(max(value, 0), 1)
|
||||
}
|
||||
|
||||
private var fillColor: Color {
|
||||
if useColorCoding {
|
||||
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
||||
}
|
||||
return .white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: trackWidth, height: trackHeight)
|
||||
|
||||
Capsule()
|
||||
.fill(fillColor)
|
||||
.frame(width: trackWidth * clampedValue, height: trackHeight)
|
||||
}
|
||||
.frame(width: trackWidth, height: trackHeight)
|
||||
.animation(.smooth(duration: 0.18), value: clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func SpeakerSymbol(_ value: CGFloat) -> String {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View {
|
|||
@Binding var icon: String
|
||||
let showSlider: Bool = false
|
||||
var sendEventBack: (CGFloat) -> Void
|
||||
@Default(.showProgressPercentages) private var showProgressPercentages
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
|
|
@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View {
|
|||
default:
|
||||
EmptyView()
|
||||
}
|
||||
if (eventType != .mic) {
|
||||
DraggableProgressBar(value: $value)
|
||||
} else {
|
||||
Text("Mic \(value > 0 ? "unmuted" : "muted")")
|
||||
switch eventType {
|
||||
case .mic:
|
||||
Text(value > 0 ? "Mic unmuted" : "Mic muted")
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.contentTransition(.numericText())
|
||||
case .volume:
|
||||
VolumeProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
case .brightness:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
case .backlight:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
default:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -88,18 +97,106 @@ struct SystemEventIndicatorModifier: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct ProgressSection: View {
|
||||
@Binding var value: CGFloat
|
||||
var showPercentages: Bool
|
||||
var colorMode: ProgressColorMode? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
DraggableProgressBar(value: $value, colorMode: colorMode)
|
||||
PercentageLabel(value: value, isVisible: showPercentages)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct VolumeProgressSection: View {
|
||||
@Binding var value: CGFloat
|
||||
var showPercentages: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if value.isZero {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.animation(.smooth(duration: 0.2), value: value.isZero)
|
||||
}
|
||||
}
|
||||
|
||||
struct PercentageLabel: View {
|
||||
let value: CGFloat
|
||||
let isVisible: Bool
|
||||
@Default(.progressBarStyle) private var progressBarStyle
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if shouldShow {
|
||||
Text(formattedValue)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.contentTransition(.numericText())
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShow: Bool {
|
||||
isVisible && progressBarStyle != .segmented
|
||||
}
|
||||
|
||||
private var formattedValue: String {
|
||||
let raw = max(0, min(100, Int(round(value * 100))))
|
||||
return String(format: "%3d", raw)
|
||||
}
|
||||
}
|
||||
|
||||
enum ProgressColorMode {
|
||||
case volume
|
||||
case battery
|
||||
}
|
||||
|
||||
private extension ProgressColorMode {
|
||||
var colorCodingMode: ColorCodingMode {
|
||||
switch self {
|
||||
case .volume:
|
||||
return .volume
|
||||
case .battery:
|
||||
return .battery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DraggableProgressBar: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var value: CGFloat
|
||||
var colorMode: ProgressColorMode? = nil
|
||||
|
||||
@State private var isDragging = false
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@Default(.progressBarStyle) private var progressBarStyle
|
||||
@Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay
|
||||
@Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay
|
||||
@Default(.useSmoothColorGradient) private var useSmoothColorGradient
|
||||
@Default(.systemEventIndicatorUseAccent) private var useAccentColor
|
||||
@Default(.systemEventIndicatorShadow) private var useShadow
|
||||
@Default(.inlineHUD) private var inlineHUD
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
Group {
|
||||
if Defaults[.progressBarStyle] == .segmented {
|
||||
if progressBarStyle == .segmented {
|
||||
// Segmented progress bar - completely different layout
|
||||
SegmentedProgressContent(value: value, geometry: geo)
|
||||
} else {
|
||||
|
|
@ -107,22 +204,7 @@ struct DraggableProgressBar: View {
|
|||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.tertiary)
|
||||
Group {
|
||||
switch Defaults[.progressBarStyle] {
|
||||
case .gradient:
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
case .hierarchical:
|
||||
Capsule()
|
||||
.fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
case .segmented:
|
||||
EmptyView() // This case won't be reached due to the outer if condition
|
||||
}
|
||||
}
|
||||
progressFill(width: geo.size.width)
|
||||
.opacity(value.isZero ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +224,7 @@ struct DraggableProgressBar: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
.frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6)
|
||||
.frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +234,59 @@ struct DraggableProgressBar: View {
|
|||
|
||||
value = max(0, min(newValue, 1))
|
||||
}
|
||||
|
||||
private func progressFill(width: CGFloat) -> some View {
|
||||
let clampedWidth = max(0, min(width * value, width))
|
||||
let accentColor = Defaults[.accentColor]
|
||||
let shadowColor: Color = {
|
||||
guard useShadow else { return .clear }
|
||||
if useAccentColor {
|
||||
return accentColor.ensureMinimumBrightness(factor: 0.7)
|
||||
}
|
||||
return .white
|
||||
}()
|
||||
|
||||
return Group {
|
||||
if shouldUseColorCoding, let mode = colorMode?.colorCodingMode {
|
||||
Capsule()
|
||||
.fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient))
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
} else {
|
||||
switch progressBarStyle {
|
||||
case .gradient:
|
||||
Capsule()
|
||||
.fill(LinearGradient(
|
||||
colors: useAccentColor
|
||||
? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)]
|
||||
: [.white, .white.opacity(0.2)],
|
||||
startPoint: .trailing,
|
||||
endPoint: .leading
|
||||
))
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
case .hierarchical:
|
||||
Capsule()
|
||||
.fill(useAccentColor ? accentColor : .white)
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
case .segmented:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldUseColorCoding: Bool {
|
||||
guard let colorMode else { return false }
|
||||
guard progressBarStyle != .segmented else { return false }
|
||||
switch colorMode {
|
||||
case .volume:
|
||||
return useColorCodedVolumeDisplay
|
||||
case .battery:
|
||||
return useColorCodedBatteryDisplay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentedProgressContent: View {
|
||||
|
|
@ -165,7 +300,7 @@ struct SegmentedProgressContent: View {
|
|||
var body: some View {
|
||||
let spacing: CGFloat = 1.5
|
||||
let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
|
||||
let barWidth = max(3.0, min(6.0, computed))
|
||||
let barWidth = max(3.0, computed)
|
||||
let activeCount = Int(round(value * CGFloat(segmentCount)))
|
||||
|
||||
HStack(spacing: spacing) {
|
||||
|
|
|
|||
|
|
@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View {
|
|||
static let expandedSize = CGSize(width: 720, height: 340)
|
||||
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@State private var sliderValue: Double = 0
|
||||
@State private var dragging: Bool = false
|
||||
@State private var isActive = true
|
||||
@State private var isExpanded = false
|
||||
@State private var isVolumeSliderVisible = false
|
||||
@State private var collapseWorkItem: DispatchWorkItem?
|
||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||
@Default(.lockScreenShowAppIcon) var showAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
|
||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||
private let expandedPanelCornerRadius: CGFloat = 52
|
||||
|
|
@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View {
|
|||
private let expandedAlbumArtCornerRadius: CGFloat = 60
|
||||
private let expandedContentSpacing: CGFloat = 40
|
||||
private let collapseTimeout: TimeInterval = 5
|
||||
private let collapsedSliderExtraHeight: CGFloat = 72
|
||||
private let expandedSliderExtraHeight: CGFloat = 88
|
||||
|
||||
private var currentSize: CGSize {
|
||||
isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||
let base = isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||
return CGSize(width: base.width, height: base.height + sliderExtraHeight)
|
||||
}
|
||||
|
||||
private var panelCornerRadius: CGFloat {
|
||||
|
|
@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View {
|
|||
panelCore
|
||||
.frame(width: currentSize.width, height: currentSize.height)
|
||||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
|
||||
.onAppear {
|
||||
sliderValue = musicManager.elapsedTime
|
||||
isActive = true
|
||||
logPanelAppearance()
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
|
||||
animated: false
|
||||
)
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onDisappear {
|
||||
isActive = false
|
||||
cancelCollapseTimer()
|
||||
isVolumeSliderVisible = false
|
||||
}
|
||||
.onChange(of: isExpanded) { _, expanded in
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: expanded,
|
||||
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
|
||||
)
|
||||
}
|
||||
.onChange(of: showMediaOutputControl) { _, enabled in
|
||||
if !enabled {
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||
isVolumeSliderVisible = false
|
||||
}
|
||||
}
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -332,8 +360,22 @@ struct LockScreenMusicPanel: View {
|
|||
private func playbackControls(alignment: Alignment) -> some View {
|
||||
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||
|
||||
return HStack(spacing: spacing) {
|
||||
// Always show shuffle on lock screen
|
||||
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
|
||||
controlsRow(alignment: alignment, spacing: spacing)
|
||||
|
||||
if shouldShowVolumeSlider {
|
||||
volumeSlider
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.top, isExpanded ? 6 : 2)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
||||
}
|
||||
|
||||
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||
HStack(spacing: spacing) {
|
||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||
musicManager.toggleShuffle()
|
||||
}
|
||||
|
|
@ -348,13 +390,15 @@ struct LockScreenMusicPanel: View {
|
|||
musicManager.nextTrack()
|
||||
}
|
||||
|
||||
// Always show repeat on lock screen
|
||||
if showMediaOutputControl {
|
||||
mediaOutputControlButton
|
||||
} else {
|
||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||
musicManager.toggleRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.top, isExpanded ? 6 : 2)
|
||||
}
|
||||
|
||||
private var playPauseButton: some View {
|
||||
|
|
@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View {
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private var mediaOutputControlButton: some View {
|
||||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||||
let iconSize: CGFloat = isExpanded ? 26 : 18
|
||||
|
||||
return Button(action: toggleVolumeSlider) {
|
||||
Image(systemName: mediaOutputIcon)
|
||||
.font(.system(size: iconSize, weight: .medium))
|
||||
.foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8))
|
||||
.frame(width: frameSize, height: frameSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private var repeatIcon: String {
|
||||
switch musicManager.repeatMode {
|
||||
case .off: return "repeat"
|
||||
|
|
@ -399,6 +458,87 @@ struct LockScreenMusicPanel: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var mediaOutputIcon: String {
|
||||
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||||
}
|
||||
|
||||
private var volumeSlider: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: volumeIconName)
|
||||
.font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(volumeModel.level) },
|
||||
set: { newValue in
|
||||
registerInteraction()
|
||||
volumeModel.setVolume(Float(newValue))
|
||||
}
|
||||
),
|
||||
in: 0 ... 1
|
||||
)
|
||||
.tint(sliderColor)
|
||||
|
||||
Text(volumePercentage)
|
||||
.font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
private var shouldShowVolumeSlider: Bool {
|
||||
showMediaOutputControl && isVolumeSliderVisible
|
||||
}
|
||||
|
||||
private var sliderExtraHeight: CGFloat {
|
||||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
}
|
||||
|
||||
private var volumeIconName: String {
|
||||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||
return "speaker.slash.fill"
|
||||
} else if volumeModel.level < 0.33 {
|
||||
return "speaker.wave.1.fill"
|
||||
} else if volumeModel.level < 0.66 {
|
||||
return "speaker.wave.2.fill"
|
||||
}
|
||||
return "speaker.wave.3.fill"
|
||||
}
|
||||
|
||||
private var volumePercentage: String {
|
||||
"\(Int(round(volumeModel.level * 100)))%"
|
||||
}
|
||||
|
||||
private func toggleVolumeSlider() {
|
||||
registerInteraction()
|
||||
let newState = !isVolumeSliderVisible
|
||||
if newState {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||
isVolumeSliderVisible = newState
|
||||
}
|
||||
|
||||
let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: targetHeight
|
||||
)
|
||||
}
|
||||
|
||||
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
|
||||
guard visible else { return 0 }
|
||||
return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBackground: some View {
|
||||
if enableBlur {
|
||||
|
|
|
|||
|
|
@ -3,22 +3,25 @@ import SwiftUI
|
|||
struct LockScreenWeatherWidget: View {
|
||||
let snapshot: LockScreenWeatherSnapshot
|
||||
|
||||
private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: snapshot.symbolName)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(snapshot.temperatureText)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.kerning(-0.4)
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
Text("•")
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
Text(locationName)
|
||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
if let charging = snapshot.charging {
|
||||
chargingSegment(for: charging)
|
||||
}
|
||||
|
||||
if let bluetooth = snapshot.bluetooth {
|
||||
bluetoothSegment(for: bluetooth)
|
||||
}
|
||||
|
||||
weatherSegment
|
||||
|
||||
if shouldShowLocation {
|
||||
locationSegment
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
|
|
@ -28,20 +31,151 @@ struct LockScreenWeatherWidget: View {
|
|||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private var weatherSegment: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: snapshot.symbolName)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(snapshot.temperatureText)
|
||||
.font(primaryFont)
|
||||
.kerning(-0.3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.9)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||
if let iconName = chargingIconName(for: info) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
if let text = formattedChargingTime(for: info) {
|
||||
Text(text)
|
||||
.font(primaryFont)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.layoutPriority(1.2)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: info.iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("\(clampedBatteryLevel(info.batteryLevel))%")
|
||||
.font(primaryFont)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.minimumScaleFactor(0.85)
|
||||
.layoutPriority(1.4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var locationSegment: some View {
|
||||
if let locationName = snapshot.locationName {
|
||||
Text(locationName)
|
||||
.font(primaryFont)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.minimumScaleFactor(0.75)
|
||||
.layoutPriority(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowLocation: Bool {
|
||||
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
|
||||
}
|
||||
|
||||
private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
|
||||
let icon = info.iconName
|
||||
return icon.isEmpty ? nil : icon
|
||||
}
|
||||
|
||||
private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
|
||||
guard let minutes = info.minutesRemaining, minutes > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hours = minutes / 60
|
||||
let remainingMinutes = minutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(remainingMinutes)m"
|
||||
}
|
||||
return "\(remainingMinutes)m"
|
||||
}
|
||||
|
||||
private func clampedBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
return String(
|
||||
var components: [String] = []
|
||||
|
||||
if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
components.append(
|
||||
String(
|
||||
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText,
|
||||
locationName
|
||||
)
|
||||
}
|
||||
|
||||
return String(
|
||||
)
|
||||
} else {
|
||||
components.append(
|
||||
String(
|
||||
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let charging = snapshot.charging {
|
||||
components.append(accessibilityChargingText(for: charging))
|
||||
}
|
||||
|
||||
if let bluetooth = snapshot.bluetooth {
|
||||
components.append(accessibilityBluetoothText(for: bluetooth))
|
||||
}
|
||||
|
||||
return components.joined(separator: ". ")
|
||||
}
|
||||
|
||||
private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||
if let minutes = charging.minutesRemaining, minutes > 0 {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute]
|
||||
formatter.unitsStyle = .full
|
||||
let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes"
|
||||
return String(
|
||||
format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"),
|
||||
duration
|
||||
)
|
||||
}
|
||||
|
||||
if charging.isPluggedIn && !charging.isCharging {
|
||||
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
|
||||
}
|
||||
|
||||
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
|
||||
}
|
||||
|
||||
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
|
||||
return String(
|
||||
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
|
||||
bluetooth.deviceName,
|
||||
bluetooth.batteryLevel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Defaults
|
|||
struct MinimalisticMusicPlayerView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -182,11 +183,15 @@ struct MinimalisticMusicPlayerView: View {
|
|||
}
|
||||
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
if showMediaOutputControl {
|
||||
MinimalisticMediaOutputButton()
|
||||
} else {
|
||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||
Task { await musicManager.toggleRepeat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
@ -216,6 +221,60 @@ struct MinimalisticMusicPlayerView: View {
|
|||
action: action
|
||||
)
|
||||
}
|
||||
private struct MinimalisticMediaOutputButton: View {
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
@State private var isPopoverPresented = false
|
||||
@State private var isHoveringPopover = false
|
||||
|
||||
var body: some View {
|
||||
MinimalisticSquircircleButton(
|
||||
icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
|
||||
fontSize: 18,
|
||||
fontWeight: .medium,
|
||||
frameSize: CGSize(width: 40, height: 40),
|
||||
cornerRadius: 16,
|
||||
foregroundColor: .white.opacity(0.85)
|
||||
) {
|
||||
isPopoverPresented.toggle()
|
||||
if isPopoverPresented {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||
MediaOutputSelectorPopover(
|
||||
routeManager: routeManager,
|
||||
volumeModel: volumeModel,
|
||||
onHoverChanged: { hovering in
|
||||
isHoveringPopover = hovering
|
||||
updateActivity()
|
||||
}
|
||||
) {
|
||||
isPopoverPresented = false
|
||||
isHoveringPopover = false
|
||||
updateActivity()
|
||||
}
|
||||
}
|
||||
.onChange(of: isPopoverPresented) { _, presented in
|
||||
if !presented {
|
||||
isHoveringPopover = false
|
||||
}
|
||||
updateActivity()
|
||||
}
|
||||
.onAppear {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onDisappear {
|
||||
vm.isMediaOutputPopoverActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivity() {
|
||||
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||
}
|
||||
}
|
||||
|
||||
private var repeatIcon: String {
|
||||
switch musicManager.repeatMode {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ struct MusicControlsView: View {
|
|||
@State private var dragging: Bool = false
|
||||
@State private var lastDragged: Date = .distantPast
|
||||
let showShuffleAndRepeat: Bool
|
||||
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
|
@ -192,11 +193,15 @@ struct MusicControlsView: View {
|
|||
MusicManager.shared.nextTrack()
|
||||
}
|
||||
if showShuffleAndRepeat {
|
||||
if showMediaOutputControl {
|
||||
MediaOutputPickerButton()
|
||||
} else {
|
||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
||||
MusicManager.shared.toggleRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +387,239 @@ struct CustomSlider: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct MediaOutputPickerButton: View {
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@State private var isPopoverPresented = false
|
||||
@State private var isHoveringPopover = false
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
|
||||
var body: some View {
|
||||
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
||||
isPopoverPresented.toggle()
|
||||
if isPopoverPresented {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||
MediaOutputSelectorPopover(
|
||||
routeManager: routeManager,
|
||||
volumeModel: volumeModel,
|
||||
onHoverChanged: { hovering in
|
||||
isHoveringPopover = hovering
|
||||
updatePopoverActivity()
|
||||
}
|
||||
) {
|
||||
isPopoverPresented = false
|
||||
isHoveringPopover = false
|
||||
updatePopoverActivity()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onChange(of: isPopoverPresented) { _, presented in
|
||||
if !presented {
|
||||
isHoveringPopover = false
|
||||
}
|
||||
updatePopoverActivity()
|
||||
}
|
||||
.onDisappear {
|
||||
vm.isMediaOutputPopoverActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonIcon: String {
|
||||
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||||
}
|
||||
|
||||
private func updatePopoverActivity() {
|
||||
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaOutputSelectorPopover: View {
|
||||
@ObservedObject var routeManager: AudioRouteManager
|
||||
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
||||
var onHoverChanged: (Bool) -> Void
|
||||
var dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
volumeSection
|
||||
Divider()
|
||||
devicesSection
|
||||
}
|
||||
.frame(width: 240)
|
||||
.padding(16)
|
||||
.onHover { hovering in
|
||||
onHoverChanged(hovering)
|
||||
}
|
||||
.onDisappear {
|
||||
onHoverChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
volumeModel.toggleMute()
|
||||
} label: {
|
||||
Image(systemName: volumeIconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.18))
|
||||
)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(volumeModel.level) },
|
||||
set: { newValue in
|
||||
volumeModel.setVolume(Float(newValue))
|
||||
}
|
||||
),
|
||||
in: 0 ... 1
|
||||
)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Output volume")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(volumePercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var devicesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Output devices")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if routeManager.devices.isEmpty {
|
||||
Text("No audio outputs available")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(routeManager.devices) { device in
|
||||
Button {
|
||||
routeManager.select(device: device)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.iconName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(device.name)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if device.id == routeManager.activeDeviceID {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeIconName: String {
|
||||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||
return "speaker.slash.fill"
|
||||
} else if volumeModel.level < 0.33 {
|
||||
return "speaker.wave.1.fill"
|
||||
} else if volumeModel.level < 0.66 {
|
||||
return "speaker.wave.2.fill"
|
||||
}
|
||||
return "speaker.wave.3.fill"
|
||||
}
|
||||
|
||||
private var volumePercentage: String {
|
||||
"\(Int(round(volumeModel.level * 100)))%"
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaOutputVolumeViewModel: ObservableObject {
|
||||
@Published var level: Float
|
||||
@Published var isMuted: Bool
|
||||
|
||||
private let controller: SystemVolumeController
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(controller: SystemVolumeController = .shared) {
|
||||
self.controller = controller
|
||||
controller.start()
|
||||
level = controller.currentVolume
|
||||
isMuted = controller.isMuted
|
||||
|
||||
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] notification in
|
||||
guard let self,
|
||||
let value = notification.userInfo?["value"] as? Float,
|
||||
let muted = notification.userInfo?["muted"] as? Bool else { return }
|
||||
self.level = value
|
||||
self.isMuted = muted
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .systemAudioRouteDidChange)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.syncFromController()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setVolume(_ value: Float) {
|
||||
level = value
|
||||
if value > 0 {
|
||||
isMuted = false
|
||||
}
|
||||
controller.setVolume(value)
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
isMuted.toggle()
|
||||
controller.toggleMute()
|
||||
}
|
||||
|
||||
private func syncFromController() {
|
||||
level = controller.currentVolume
|
||||
isMuted = controller.isMuted
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchHomeView(
|
||||
albumArtNamespace: Namespace().wrappedValue
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ struct GeneralSettings: View {
|
|||
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
|
||||
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
|
||||
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
|
||||
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
|
||||
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -324,6 +328,11 @@ struct GeneralSettings: View {
|
|||
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
||||
if enableLockScreenWeatherWidget {
|
||||
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
|
||||
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
|
||||
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
|
||||
}
|
||||
|
||||
Button("Copy Latest Crash Report") {
|
||||
copyLatestCrashReport()
|
||||
|
|
@ -666,6 +675,9 @@ struct HUD: View {
|
|||
|
||||
Section {
|
||||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
||||
Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator)
|
||||
Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText)
|
||||
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
|
||||
} header: {
|
||||
Text("Bluetooth Audio Devices")
|
||||
} footer: {
|
||||
|
|
@ -675,27 +687,30 @@ struct HUD: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
let colorCodingDisabled = progressBarStyle == .segmented
|
||||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
.disabled(colorCodingDisabled)
|
||||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
.disabled(colorCodingDisabled)
|
||||
|
||||
if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||||
if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||||
}
|
||||
|
||||
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
|
||||
} header: {
|
||||
Text("Color-Coded Progress Bars")
|
||||
} footer: {
|
||||
if progressBarStyle != .gradient {
|
||||
Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.")
|
||||
if progressBarStyle == .segmented {
|
||||
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if Defaults[.useSmoothColorGradient] {
|
||||
Text("Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)")
|
||||
Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)")
|
||||
Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
@ -744,6 +759,7 @@ struct Media: View {
|
|||
@Default(.enableSneakPeek) private var enableSneakPeek
|
||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -784,6 +800,10 @@ struct Media: View {
|
|||
customBadge(text: "Beta")
|
||||
}
|
||||
}
|
||||
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||
Text("Replace repeat button with media output control")
|
||||
}
|
||||
.disabled(!showShuffleAndRepeat)
|
||||
} header: {
|
||||
Text("Media controls")
|
||||
}
|
||||
|
|
|
|||
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import Combine
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
struct AudioOutputDevice: Identifiable, Equatable {
|
||||
let id: AudioDeviceID
|
||||
let name: String
|
||||
let transportType: UInt32
|
||||
|
||||
var iconName: String {
|
||||
let normalizedName = name.lowercased()
|
||||
|
||||
if normalizedName.contains("airpods") {
|
||||
return "airpodspro"
|
||||
}
|
||||
|
||||
if normalizedName.contains("macbook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
|
||||
if normalizedName.contains("headphone") || normalizedName.contains("headset") {
|
||||
return "headphones"
|
||||
}
|
||||
|
||||
if normalizedName.contains("beats") {
|
||||
return "headphones"
|
||||
}
|
||||
|
||||
if normalizedName.contains("homepod") {
|
||||
return "hifispeaker.2"
|
||||
}
|
||||
|
||||
switch transportType {
|
||||
case kAudioDeviceTransportTypeBluetooth:
|
||||
if normalizedName.contains("speaker") {
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
return "headphones"
|
||||
case kAudioDeviceTransportTypeAirPlay:
|
||||
return "airplayaudio"
|
||||
case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI:
|
||||
return "tv"
|
||||
case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeFireWire:
|
||||
return "hifispeaker.2"
|
||||
case kAudioDeviceTransportTypePCI, kAudioDeviceTransportTypeVirtual:
|
||||
return "speaker.wave.2"
|
||||
case kAudioDeviceTransportTypeBuiltIn:
|
||||
return normalizedName.contains("display") ? "tv" : "speaker.wave.2"
|
||||
default:
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AudioRouteManager: ObservableObject {
|
||||
static let shared = AudioRouteManager()
|
||||
|
||||
@Published private(set) var devices: [AudioOutputDevice] = []
|
||||
@Published private(set) var activeDeviceID: AudioDeviceID = 0
|
||||
|
||||
private let queue = DispatchQueue(label: "com.dynamicisland.audio-route", qos: .userInitiated)
|
||||
|
||||
private init() {
|
||||
refreshDevices()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange),
|
||||
name: .systemAudioRouteDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
var activeDevice: AudioOutputDevice? {
|
||||
devices.first { $0.id == activeDeviceID }
|
||||
}
|
||||
|
||||
func refreshDevices() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let defaultID = self.fetchDefaultOutputDevice()
|
||||
let deviceInfos = self.fetchOutputDeviceIDs().compactMap(self.makeDeviceInfo)
|
||||
let sortedDevices = deviceInfos.sorted { lhs, rhs in
|
||||
if lhs.id == defaultID { return true }
|
||||
if rhs.id == defaultID { return false }
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.activeDeviceID = defaultID
|
||||
self.devices = sortedDevices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func select(device: AudioOutputDevice) {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.setDefaultOutputDevice(device.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleRouteChange() {
|
||||
refreshDevices()
|
||||
}
|
||||
|
||||
private func fetchOutputDeviceIDs() -> [AudioDeviceID] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var dataSize: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize
|
||||
) == noErr else {
|
||||
return []
|
||||
}
|
||||
|
||||
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
|
||||
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize,
|
||||
&deviceIDs
|
||||
)
|
||||
if status != noErr {
|
||||
return []
|
||||
}
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
private func makeDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? {
|
||||
guard deviceHasOutputChannels(deviceID) else { return nil }
|
||||
let name = deviceName(for: deviceID) ?? "Unknown Device"
|
||||
let transport = transportType(for: deviceID)
|
||||
return AudioOutputDevice(id: deviceID, name: name, transportType: transport)
|
||||
}
|
||||
|
||||
private func deviceHasOutputChannels(_ deviceID: AudioDeviceID) -> Bool {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeOutput,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var dataSize: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr else {
|
||||
return false
|
||||
}
|
||||
|
||||
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { buffer.deallocate() }
|
||||
|
||||
guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, buffer) == noErr else {
|
||||
return false
|
||||
}
|
||||
|
||||
let audioBufferListPointer = buffer.assumingMemoryBound(to: AudioBufferList.self)
|
||||
let audioBuffers = UnsafeMutableAudioBufferListPointer(audioBufferListPointer)
|
||||
let channelCount = audioBuffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
return channelCount > 0
|
||||
}
|
||||
|
||||
private func deviceName(for deviceID: AudioDeviceID) -> String? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioObjectPropertyName,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var name: CFString = "" as CFString
|
||||
var dataSize = UInt32(MemoryLayout<CFString?>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &name)
|
||||
guard status == noErr else { return nil }
|
||||
return name as String
|
||||
}
|
||||
|
||||
private func transportType(for deviceID: AudioDeviceID) -> UInt32 {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyTransportType,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var type: UInt32 = kAudioDeviceTransportTypeUnknown
|
||||
var dataSize = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &type)
|
||||
return status == noErr ? type : kAudioDeviceTransportTypeUnknown
|
||||
}
|
||||
|
||||
private func fetchDefaultOutputDevice() -> AudioDeviceID {
|
||||
var deviceID = AudioDeviceID()
|
||||
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize,
|
||||
&deviceID
|
||||
)
|
||||
return status == noErr ? deviceID : 0
|
||||
}
|
||||
|
||||
private func setDefaultOutputDevice(_ deviceID: AudioDeviceID) {
|
||||
var target = deviceID
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectSetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
UInt32(MemoryLayout<AudioDeviceID>.size),
|
||||
&target
|
||||
)
|
||||
if status == noErr {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.activeDeviceID = deviceID
|
||||
}
|
||||
refreshDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import AppKit
|
|||
import Defaults
|
||||
import SwiftUI
|
||||
import IOBluetooth
|
||||
import IOKit
|
||||
|
||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||
class BluetoothAudioManager: ObservableObject {
|
||||
|
|
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
|
|||
private var cancellables = Set<AnyCancellable>()
|
||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||
private var pollingTimer: Timer?
|
||||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||||
|
||||
@Published private(set) var batteryStatus: [String: String] = [:]
|
||||
|
||||
private var batteryStatusByAddress: [String: Int] = [:]
|
||||
private var batteryStatusByName: [String: Int] = [:]
|
||||
private var missingBatteryLog: Set<String> = []
|
||||
private var lastBatteryStatusUpdate: Date?
|
||||
private let batteryStatusUpdateInterval: TimeInterval = 20
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
|
|
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
// Update connection state
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
if let lastDevice = connectedDevices.last {
|
||||
lastConnectedDevice = lastDevice
|
||||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||||
|
|
@ -202,11 +214,17 @@ class BluetoothAudioManager: ObservableObject {
|
|||
lastConnectedDevice = audioDevice
|
||||
isBluetoothAudioConnected = true
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
// Show HUD for new connection
|
||||
if let refreshedDevice = connectedDevices.last {
|
||||
showDeviceConnectedHUD(refreshedDevice)
|
||||
} else {
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the list of connected devices (for disconnections)
|
||||
private func updateConnectedDevices() {
|
||||
|
|
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||||
|
|
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
|
|||
|
||||
/// Extracts battery level from Bluetooth device
|
||||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||||
// Try to read battery level from device
|
||||
// Note: This may require additional permissions or may not be available for all devices
|
||||
updateBatteryStatuses()
|
||||
|
||||
// Placeholder: Return nil if not available
|
||||
// TODO: Implement actual battery level reading if device supports it
|
||||
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
logMissingBatteryInfo(for: device)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +411,590 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshBatteryLevelsForConnectedDevices() {
|
||||
updateBatteryStatuses(force: true)
|
||||
|
||||
var updatedDevices: [BluetoothAudioDevice] = []
|
||||
for device in connectedDevices {
|
||||
let refreshedLevel =
|
||||
batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
?? device.batteryLevel
|
||||
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||||
updatedDevices.append(updatedDevice)
|
||||
|
||||
if let refreshedLevel {
|
||||
clearMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
} else {
|
||||
logMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
}
|
||||
}
|
||||
|
||||
connectedDevices = updatedDevices
|
||||
if let last = connectedDevices.last {
|
||||
lastConnectedDevice = last
|
||||
}
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
let normalizedTarget = normalizeBluetoothIdentifier(address)
|
||||
var bestMatch: Int?
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forName name: String) -> Int? {
|
||||
guard !name.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
var bestMatch: Int?
|
||||
|
||||
for value in deviceCache.values {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
|
||||
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forName name: String) -> Int? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = normalizeProductName(trimmed)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
if let value = batteryStatusByName[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func updateBatteryStatuses(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force, let lastBatteryStatusUpdate,
|
||||
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
|
||||
return
|
||||
}
|
||||
|
||||
var combinedAddressPercentages: [String: Int] = [:]
|
||||
var combinedNamePercentages: [String: Int] = [:]
|
||||
|
||||
let registry = collectRegistryBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
|
||||
|
||||
let defaults = collectDefaultsBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
|
||||
|
||||
let profiler = collectSystemProfilerBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
||||
|
||||
var statuses: [String: String] = [:]
|
||||
for (key, value) in combinedAddressPercentages {
|
||||
statuses[key] = String(clampBatteryPercentage(value))
|
||||
}
|
||||
|
||||
let applyUpdates = {
|
||||
self.batteryStatus = statuses
|
||||
self.batteryStatusByAddress = combinedAddressPercentages
|
||||
self.batteryStatusByName = combinedNamePercentages
|
||||
self.lastBatteryStatusUpdate = now
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
applyUpdates()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: applyUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
|
||||
guard !source.isEmpty else { return }
|
||||
for (key, value) in source {
|
||||
guard !key.isEmpty else { continue }
|
||||
if let existing = target[key] {
|
||||
target[key] = max(existing, value)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
var iterator = io_iterator_t()
|
||||
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
|
||||
|
||||
let servicePort: mach_port_t
|
||||
if #available(macOS 12.0, *) {
|
||||
servicePort = kIOMainPortDefault
|
||||
} else {
|
||||
servicePort = kIOMasterPortDefault
|
||||
}
|
||||
|
||||
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
|
||||
|
||||
if kernResult == KERN_SUCCESS {
|
||||
var entry: io_object_t = IOIteratorNext(iterator)
|
||||
while entry != 0 {
|
||||
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
|
||||
let normalizedPercent = clampBatteryPercentage(percent)
|
||||
|
||||
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
|
||||
for key in identifierKeys {
|
||||
if let identifier = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
|
||||
if !normalizedIdentifier.isEmpty {
|
||||
if let existing = addressPercentages[normalizedIdentifier] {
|
||||
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
addressPercentages[normalizedIdentifier] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nameKeys = [
|
||||
"Product",
|
||||
"ProductName",
|
||||
"DeviceName",
|
||||
"Name",
|
||||
"USB Product Name",
|
||||
"Bluetooth Product Name"
|
||||
]
|
||||
|
||||
for key in nameKeys {
|
||||
if let product = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedName = normalizeProductName(product)
|
||||
if !normalizedName.isEmpty {
|
||||
if let existing = namePercentages[normalizedName] {
|
||||
namePercentages[normalizedName] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
namePercentages[normalizedName] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(entry)
|
||||
entry = IOIteratorNext(iterator)
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(iterator)
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
guard let level = extractBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
|
||||
let normalizedKey = normalizeBluetoothIdentifier(key)
|
||||
if !normalizedKey.isEmpty {
|
||||
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for identifier in identifiersFromDeviceCachePayload(payload) {
|
||||
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
|
||||
let normalizedName = normalizeProductName(name)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let root = systemProfilerBluetoothDictionary() else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
|
||||
for deviceGroup in connectedList {
|
||||
for (rawName, payload) in deviceGroup {
|
||||
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(percent)
|
||||
|
||||
let normalizedName = normalizeProductName(rawName)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for address in profilerAddressCandidates(from: payload) {
|
||||
let normalizedAddress = normalizeBluetoothIdentifier(address)
|
||||
if !normalizedAddress.isEmpty {
|
||||
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
||||
var identifiers: Set<String> = []
|
||||
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
||||
|
||||
for key in candidateKeys {
|
||||
if let value = payload[key] as? String {
|
||||
let normalized = normalizeBluetoothIdentifier(value)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8) {
|
||||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(identifiers)
|
||||
}
|
||||
|
||||
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
|
||||
let process = Process()
|
||||
process.launchPath = "/usr/sbin/system_profiler"
|
||||
process.arguments = ["SPBluetoothDataType", "-json"]
|
||||
|
||||
let outputPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
let errorPipe = Pipe()
|
||||
process.standardError = errorPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
guard !data.isEmpty else { return nil }
|
||||
|
||||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
|
||||
let root = entries.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let preferredKeys = [
|
||||
"device_batteryLevelCase",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"device_batteryLevel",
|
||||
"device_batteryLevelCombined",
|
||||
"device_batteryPercentCombined",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level",
|
||||
"Battery Level",
|
||||
"BatteryPercent"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in preferredKeys {
|
||||
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty {
|
||||
for (key, raw) in payload where key.lowercased().contains("battery") {
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let validValues = values.filter { $0 >= 0 }
|
||||
return validValues.max()
|
||||
}
|
||||
|
||||
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
|
||||
var addresses: Set<String> = []
|
||||
let keys = [
|
||||
"device_address",
|
||||
"device_mac_address",
|
||||
"device_serial_num",
|
||||
"device_serialNumber",
|
||||
"device_serial_number"
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let value = payload[key] as? String, !value.isEmpty {
|
||||
addresses.insert(value)
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
addresses.insert(ascii)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(addresses)
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
let normalized = normalizeBluetoothIdentifier(address)
|
||||
if let value = batteryStatusByAddress[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let keys = [
|
||||
"BatteryPercent",
|
||||
"BatteryPercentCase",
|
||||
"BatteryPercentLeft",
|
||||
"BatteryPercentRight",
|
||||
"BatteryPercentSingle",
|
||||
"BatteryPercentCombined",
|
||||
"BatteryPercentMain",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in keys {
|
||||
guard let raw = payload[key] else { continue }
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty,
|
||||
let services = payload["Services"] as? [[String: Any]] {
|
||||
for service in services {
|
||||
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
|
||||
for value in serviceValues.values {
|
||||
if let converted = convertToBatteryPercentage(value) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.max()
|
||||
}
|
||||
|
||||
private func convertToBatteryPercentage(_ value: Any) -> Int? {
|
||||
if let number = value as? Int {
|
||||
if number == 1 {
|
||||
return 100
|
||||
}
|
||||
return number
|
||||
}
|
||||
if let number = value as? Double {
|
||||
if number <= 1.0 {
|
||||
return Int(number * 100)
|
||||
}
|
||||
return Int(number)
|
||||
}
|
||||
if let string = value as? String {
|
||||
let trimmed = string.replacingOccurrences(of: "%", with: "")
|
||||
if let doubleValue = Double(trimmed) {
|
||||
if doubleValue <= 1.0 {
|
||||
return Int(doubleValue * 100)
|
||||
}
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func clampBatteryPercentage(_ value: Int) -> Int {
|
||||
min(max(value, 0), 100)
|
||||
}
|
||||
|
||||
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
|
||||
if normalizeBluetoothIdentifier(key) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
let candidateFields: [String?] = [
|
||||
payload["DeviceAddress"] as? String,
|
||||
payload["Address"] as? String,
|
||||
payload["BD_ADDR"] as? String,
|
||||
payload["SerialNumber"] as? String
|
||||
]
|
||||
|
||||
for field in candidateFields {
|
||||
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if let deviceAddressData = payload["DeviceAddress"] as? Data,
|
||||
let ascii = String(data: deviceAddressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let addressData = payload["BD_ADDR"] as? Data,
|
||||
let ascii = String(data: addressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let serialData = payload["SerialNumber"] as? Data,
|
||||
let ascii = String(data: serialData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
logMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
clearMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
guard !missingBatteryLog.contains(key) else { return }
|
||||
missingBatteryLog.insert(key)
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))")
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
missingBatteryLog.remove(key)
|
||||
}
|
||||
|
||||
private func missingBatteryKey(name: String, address: String) -> String {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let normalizedName = normalizeProductName(trimmedName)
|
||||
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
|
||||
|
||||
if normalizedName.isEmpty && normalizedAddress.isEmpty {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return normalizedName + "#" + normalizedAddress
|
||||
}
|
||||
|
||||
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
|
||||
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = unmanaged.takeRetainedValue()
|
||||
|
||||
if let string = value as? String, !string.isEmpty {
|
||||
return string
|
||||
}
|
||||
|
||||
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
return ascii
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeBluetoothIdentifier(_ value: String) -> String {
|
||||
return value
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: ":", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
}
|
||||
|
||||
private func normalizeProductName(_ name: String) -> String {
|
||||
let components = name
|
||||
.lowercased()
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
return components.joined()
|
||||
}
|
||||
|
||||
// MARK: - HUD Display
|
||||
|
||||
/// Shows HUD notification for newly connected audio device
|
||||
|
|
@ -382,12 +1004,20 @@ class BluetoothAudioManager: ObservableObject {
|
|||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||
|
||||
// Convert battery percentage to 0.0-1.0 value
|
||||
let batteryValue: CGFloat = if let battery = device.batteryLevel {
|
||||
CGFloat(battery) / 100.0
|
||||
let reportedBattery = device.batteryLevel
|
||||
?? batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
|
||||
let batteryValue: CGFloat = if let battery = reportedBattery {
|
||||
CGFloat(clampBatteryPercentage(battery)) / 100.0
|
||||
} else {
|
||||
0.0 // 0 means battery info not available
|
||||
}
|
||||
|
||||
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
||||
|
||||
// Show HUD via coordinator
|
||||
coordinator.toggleSneakPeek(
|
||||
status: true,
|
||||
|
|
@ -411,16 +1041,47 @@ class BluetoothAudioManager: ObservableObject {
|
|||
observers.removeAll()
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshConnectedDeviceBatteries() {
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct BluetoothAudioDevice: Identifiable {
|
||||
let id = UUID()
|
||||
let id: UUID
|
||||
let name: String
|
||||
let address: String
|
||||
let batteryLevel: Int? // 0-100, nil if not available
|
||||
let deviceType: BluetoothAudioDeviceType
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
address: String,
|
||||
batteryLevel: Int?,
|
||||
deviceType: BluetoothAudioDeviceType
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.batteryLevel = batteryLevel
|
||||
self.deviceType = deviceType
|
||||
}
|
||||
}
|
||||
|
||||
extension BluetoothAudioDevice {
|
||||
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
|
||||
BluetoothAudioDevice(
|
||||
id: id,
|
||||
name: name,
|
||||
address: address,
|
||||
batteryLevel: batteryLevel,
|
||||
deviceType: deviceType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum BluetoothAudioDeviceType {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import SwiftUI
|
|||
import AppKit
|
||||
import SkyLightWindow
|
||||
import Defaults
|
||||
import QuartzCore
|
||||
|
||||
@MainActor
|
||||
class LockScreenPanelManager {
|
||||
|
|
@ -17,6 +18,8 @@ class LockScreenPanelManager {
|
|||
private var panelWindow: NSWindow?
|
||||
private var hasDelegated = false
|
||||
private var collapsedFrame: NSRect?
|
||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||
private let expandedPanelCornerRadius: CGFloat = 52
|
||||
|
||||
private init() {
|
||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||
|
|
@ -76,7 +79,16 @@ class LockScreenPanelManager {
|
|||
}
|
||||
|
||||
window.setFrame(targetFrame, display: true)
|
||||
window.contentView = NSHostingView(rootView: LockScreenMusicPanel())
|
||||
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
|
||||
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
||||
window.contentView = hosting
|
||||
|
||||
// Ensure the underlying window content is clipped to rounded corners
|
||||
if let content = window.contentView {
|
||||
content.wantsLayer = true
|
||||
content.layer?.masksToBounds = true
|
||||
content.layer?.cornerRadius = collapsedPanelCornerRadius
|
||||
}
|
||||
|
||||
if !hasDelegated {
|
||||
SkyLightOperator.shared.delegateWindow(window)
|
||||
|
|
@ -89,21 +101,38 @@ class LockScreenPanelManager {
|
|||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
||||
}
|
||||
|
||||
func updatePanelSize(expanded: Bool, animated: Bool = true) {
|
||||
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
|
||||
guard let window = panelWindow, let baseFrame = collapsedFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||
let originX = baseFrame.midX - (targetSize.width / 2)
|
||||
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||
let targetWidth = baseSize.width
|
||||
let targetHeight = baseSize.height + additionalHeight
|
||||
let originX = baseFrame.midX - (targetWidth / 2)
|
||||
let originY = baseFrame.origin.y
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height)
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
|
||||
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.45
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(targetFrame, display: true)
|
||||
}
|
||||
|
||||
// Update corner radius to match the SwiftUI panel's style
|
||||
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||||
if animated {
|
||||
CATransaction.begin()
|
||||
CATransaction.setAnimationDuration(0.28)
|
||||
window.contentView?.layer?.cornerRadius = targetRadius
|
||||
CATransaction.commit()
|
||||
} else {
|
||||
window.contentView?.layer?.cornerRadius = targetRadius
|
||||
}
|
||||
}
|
||||
|
||||
func hidePanel() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
import Defaults
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class LockScreenWeatherManager: ObservableObject {
|
||||
|
|
@ -11,8 +12,12 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
private let provider = LockScreenWeatherProvider()
|
||||
private let locationProvider = LockScreenWeatherLocationProvider()
|
||||
private var lastFetchDate: Date?
|
||||
private var latestWeatherPayload: LockScreenWeatherSnapshot?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
observeAccessoryChanges()
|
||||
}
|
||||
|
||||
func prepareLocationAccess() {
|
||||
locationProvider.prepareAuthorization()
|
||||
|
|
@ -28,9 +33,15 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(force: self.snapshot == nil)
|
||||
if let snapshot = self.snapshot {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
let snapshot = await self.refresh(force: self.snapshot == nil)
|
||||
|
||||
guard LockScreenManager.shared.currentLockStatus else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
return
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
self.deliver(snapshot, forceShow: true)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
|
@ -41,32 +52,222 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
@discardableResult
|
||||
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
|
||||
let interval = Defaults[.lockScreenWeatherRefreshInterval]
|
||||
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
|
||||
if let snapshot = snapshot {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
if let payload = latestWeatherPayload {
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
return
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
} else if let snapshot = snapshot {
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
do {
|
||||
let location = await locationProvider.currentLocation()
|
||||
let snapshot = try await provider.fetchSnapshot(location: location)
|
||||
let payload = try await provider.fetchSnapshot(location: location)
|
||||
latestWeatherPayload = payload
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
lastFetchDate = Date()
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
} catch {
|
||||
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
|
||||
|
||||
let fallback = LockScreenWeatherSnapshot(
|
||||
temperatureText: snapshot?.temperatureText ?? "--",
|
||||
symbolName: snapshot?.symbolName ?? "cloud.fill",
|
||||
description: snapshot?.description ?? "",
|
||||
locationName: snapshot?.locationName,
|
||||
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil,
|
||||
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
|
||||
showsLocation: snapshot?.showsLocation ?? false
|
||||
)
|
||||
|
||||
self.snapshot = fallback
|
||||
deliver(fallback, forceShow: false)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private func observeAccessoryChanges() {
|
||||
let bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
bluetoothManager.$connectedDevices
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
bluetoothManager.$lastConnectedDevice
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let batteryPublishers: [AnyPublisher<Void, Never>] = [
|
||||
battery.$isCharging.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$timeToFullCharge.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(batteryPublishers)
|
||||
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let defaultsPublishers: [AnyPublisher<Void, Never>] = [
|
||||
Defaults.publisher(.lockScreenWeatherShowsLocation, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsCharging, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(defaultsPublishers)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
|
||||
guard let payload = latestWeatherPayload else { return }
|
||||
|
||||
if triggerBluetoothRefresh {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
}
|
||||
|
||||
private func deliver(_ snapshot: LockScreenWeatherSnapshot, forceShow: Bool) {
|
||||
guard LockScreenManager.shared.currentLockStatus else { return }
|
||||
|
||||
if forceShow {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot {
|
||||
let locationName = payload.locationName
|
||||
let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
|
||||
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
|
||||
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
|
||||
|
||||
return LockScreenWeatherSnapshot(
|
||||
temperatureText: payload.temperatureText,
|
||||
symbolName: payload.symbolName,
|
||||
description: payload.description,
|
||||
locationName: locationName,
|
||||
charging: chargingInfo,
|
||||
bluetooth: bluetoothInfo,
|
||||
showsLocation: shouldShowLocation
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? {
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let macStatus = MacBatteryManager.shared.currentStatus()
|
||||
|
||||
let isPluggedIn = battery.isPluggedIn || battery.isCharging
|
||||
let isCharging = macStatus.isCharging || battery.isCharging
|
||||
|
||||
guard isPluggedIn || isCharging else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil)
|
||||
let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil
|
||||
|
||||
return LockScreenWeatherSnapshot.ChargingInfo(
|
||||
minutesRemaining: remaining,
|
||||
isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn
|
||||
)
|
||||
}
|
||||
|
||||
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
|
||||
let manager = BluetoothAudioManager.shared
|
||||
|
||||
guard manager.isBluetoothAudioConnected else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let device = manager.connectedDevices.last ?? manager.lastConnectedDevice
|
||||
|
||||
guard let device else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let batteryLevel = device.batteryLevel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LockScreenWeatherSnapshot.BluetoothInfo(
|
||||
deviceName: device.name,
|
||||
batteryLevel: clampBluetoothBatteryLevel(batteryLevel),
|
||||
iconName: device.deviceType.sfSymbol
|
||||
)
|
||||
}
|
||||
|
||||
private func clampBluetoothBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockScreenWeatherSnapshot: Equatable {
|
||||
struct ChargingInfo: Equatable {
|
||||
let minutesRemaining: Int?
|
||||
let isCharging: Bool
|
||||
let isPluggedIn: Bool
|
||||
|
||||
var iconName: String {
|
||||
if isCharging {
|
||||
return "bolt.fill"
|
||||
}
|
||||
if isPluggedIn {
|
||||
return "powerplug.portrait.fill"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
struct BluetoothInfo: Equatable {
|
||||
let deviceName: String
|
||||
let batteryLevel: Int
|
||||
let iconName: String
|
||||
}
|
||||
|
||||
let temperatureText: String
|
||||
let symbolName: String
|
||||
let description: String
|
||||
let locationName: String?
|
||||
let charging: ChargingInfo?
|
||||
let bluetooth: BluetoothInfo?
|
||||
let showsLocation: Bool
|
||||
}
|
||||
|
||||
private actor LockScreenWeatherProvider {
|
||||
|
|
@ -122,7 +323,10 @@ private actor LockScreenWeatherProvider {
|
|||
temperatureText: temperatureText,
|
||||
symbolName: symbol,
|
||||
description: description,
|
||||
locationName: locationName
|
||||
locationName: locationName,
|
||||
charging: nil,
|
||||
bluetooth: nil,
|
||||
showsLocation: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Foundation
|
||||
import IOKit.ps
|
||||
|
||||
/// Lightweight helper for querying macOS battery charging status and ETA.
|
||||
final class MacBatteryManager {
|
||||
static let shared = MacBatteryManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
struct BatteryStatus {
|
||||
let timeRemainingMinutes: Int?
|
||||
let isCharging: Bool
|
||||
let percentage: Int?
|
||||
}
|
||||
|
||||
func currentStatus() -> BatteryStatus {
|
||||
guard let sourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
||||
let sourcesList = IOPSCopyPowerSourcesList(sourcesInfo)?.takeRetainedValue() as? [CFTypeRef] else {
|
||||
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
|
||||
}
|
||||
|
||||
for source in sourcesList {
|
||||
guard let description = IOPSGetPowerSourceDescription(sourcesInfo, source)?.takeUnretainedValue() as? [String: Any],
|
||||
let type = description[kIOPSTypeKey] as? String,
|
||||
type == kIOPSInternalBatteryType else {
|
||||
continue
|
||||
}
|
||||
|
||||
let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
||||
let timeRemaining = description[kIOPSTimeToFullChargeKey] as? Int
|
||||
let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int
|
||||
let maxCapacity = description[kIOPSMaxCapacityKey] as? Int
|
||||
|
||||
let percentage: Int?
|
||||
if let current = currentCapacity, let max = maxCapacity, max > 0 {
|
||||
percentage = (current * 100) / max
|
||||
} else {
|
||||
percentage = nil
|
||||
}
|
||||
|
||||
return BatteryStatus(
|
||||
timeRemainingMinutes: timeRemaining,
|
||||
isCharging: isCharging,
|
||||
percentage: percentage
|
||||
)
|
||||
}
|
||||
|
||||
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
|
||||
}
|
||||
|
||||
func formattedTimeToFullCharge() -> String? {
|
||||
let status = currentStatus()
|
||||
guard status.isCharging, let minutes = status.timeRemainingMinutes, minutes > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hours = minutes / 60
|
||||
let remainingMinutes = minutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(remainingMinutes)m"
|
||||
}
|
||||
return "\(remainingMinutes)m"
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
// MARK: - HUD Dispatch
|
||||
|
||||
private func sendVolumeNotification(value: Float) {
|
||||
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
||||
return
|
||||
}
|
||||
coordinator?.toggleSneakPeek(
|
||||
status: true,
|
||||
type: .volume,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,38 @@ extension Notification.Name {
|
|||
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
||||
}
|
||||
|
||||
final class HUDSuppressionCoordinator {
|
||||
static let shared = HUDSuppressionCoordinator()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.dynamicisland.hud-suppression")
|
||||
private var volumeSuppressedUntil: Date?
|
||||
|
||||
func suppressVolumeHUD(for interval: TimeInterval) {
|
||||
guard interval > 0 else { return }
|
||||
queue.sync {
|
||||
let proposed = Date().addingTimeInterval(interval)
|
||||
if let current = volumeSuppressedUntil {
|
||||
volumeSuppressedUntil = max(current, proposed)
|
||||
} else {
|
||||
volumeSuppressedUntil = proposed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shouldSuppressVolumeHUD: Bool {
|
||||
queue.sync {
|
||||
guard let expiration = volumeSuppressedUntil else {
|
||||
return false
|
||||
}
|
||||
if Date() < expiration {
|
||||
return true
|
||||
}
|
||||
volumeSuppressedUntil = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemVolumeController {
|
||||
static let shared = SystemVolumeController()
|
||||
|
||||
|
|
@ -46,6 +78,10 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
guard delta != 0 else { return }
|
||||
if isMuted {
|
||||
setMuted(false)
|
||||
}
|
||||
var newValue = currentVolume + delta
|
||||
newValue = max(0, min(1, newValue))
|
||||
setVolume(newValue)
|
||||
|
|
@ -64,19 +100,54 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
func setVolume(_ value: Float) {
|
||||
var volume = max(0, min(1, value))
|
||||
let clamped = max(0, min(1, value))
|
||||
if isMuted && clamped > 0 {
|
||||
setMuted(false)
|
||||
}
|
||||
|
||||
let elements = volumeElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var volume = clamped
|
||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set volume: \(status)")
|
||||
}
|
||||
} else {
|
||||
for element in elements {
|
||||
var volume = clamped
|
||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set volume for element \(element): \(status)")
|
||||
} else {
|
||||
cache(element: element, for: kAudioDevicePropertyVolumeScalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyCurrentState()
|
||||
}
|
||||
|
||||
func setMuted(_ muted: Bool) {
|
||||
var muteFlag: UInt32 = muted ? 1 : 0
|
||||
let elements = muteElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set mute state: \(status)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for element in elements {
|
||||
var value = muteFlag
|
||||
let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set mute state for element \(element): \(status)")
|
||||
} else {
|
||||
cache(element: element, for: kAudioDevicePropertyMute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
|
@ -159,6 +230,9 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
private func getVolume() -> Float {
|
||||
let elements = volumeElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var volume = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
|
|
@ -167,16 +241,76 @@ final class SystemVolumeController {
|
|||
return volume
|
||||
}
|
||||
|
||||
var masterVolume: Float?
|
||||
var accumulator: Float = 0
|
||||
var count: Float = 0
|
||||
|
||||
for element in elements {
|
||||
var value = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value)
|
||||
if status == noErr {
|
||||
if element == kAudioObjectPropertyElementMaster {
|
||||
masterVolume = value
|
||||
}
|
||||
accumulator += value
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
if let masterVolume {
|
||||
return masterVolume
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return accumulator / count
|
||||
}
|
||||
|
||||
var fallback = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Unable to fetch fallback volume: \(status)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func getMuteState() -> Bool {
|
||||
let elements = muteElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var mute: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
||||
if status != noErr {
|
||||
// Some devices do not support mute; treat as not muted
|
||||
return false
|
||||
}
|
||||
return mute != 0
|
||||
}
|
||||
|
||||
var retrieved = false
|
||||
var allMuted = true
|
||||
|
||||
for element in elements {
|
||||
var value: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||
if status == noErr {
|
||||
retrieved = true
|
||||
if value == 0 {
|
||||
allMuted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if retrieved {
|
||||
return allMuted
|
||||
}
|
||||
|
||||
var fallback: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, data: &fallback)
|
||||
if status != noErr {
|
||||
return false
|
||||
}
|
||||
return fallback != 0
|
||||
}
|
||||
|
||||
private func refreshPropertyElements() {
|
||||
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
|
||||
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
|
||||
|
|
@ -264,6 +398,38 @@ final class SystemVolumeController {
|
|||
}
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
private func getData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
||||
return kAudioHardwareUnknownPropertyError
|
||||
}
|
||||
var size = UInt32(MemoryLayout<T>.size)
|
||||
return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
||||
}
|
||||
|
||||
private func setData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
||||
return kAudioHardwareUnknownPropertyError
|
||||
}
|
||||
let size = UInt32(MemoryLayout<T>.size)
|
||||
return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
||||
}
|
||||
|
||||
private func volumeElements() -> [AudioObjectPropertyElement] {
|
||||
candidateElements.filter { element in
|
||||
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
||||
return propertyExists(deviceID: currentDeviceID, address: &address)
|
||||
}
|
||||
}
|
||||
|
||||
private func muteElements() -> [AudioObjectPropertyElement] {
|
||||
candidateElements.filter { element in
|
||||
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
||||
return propertyExists(deviceID: currentDeviceID, address: &address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemBrightnessController {
|
||||
|
|
@ -275,9 +441,17 @@ final class SystemBrightnessController {
|
|||
private var observers: [NSObjectProtocol] = []
|
||||
private var notificationsInstalled = false
|
||||
private var displayID: CGDirectDisplayID = CGMainDisplayID()
|
||||
private var brightnessAnimationTimer: Timer?
|
||||
private var brightnessAnimationStart: Float = 0
|
||||
private var brightnessAnimationTarget: Float = 0
|
||||
private var brightnessAnimationStartDate: Date?
|
||||
private let brightnessAnimationDuration: TimeInterval = 0.18
|
||||
private let brightnessAnimationSteps = 10
|
||||
private var lastEmittedBrightness: Float = 0.5
|
||||
|
||||
private init() {
|
||||
registerExternalNotifications()
|
||||
lastEmittedBrightness = currentBrightness
|
||||
}
|
||||
|
||||
func start() {
|
||||
|
|
@ -286,6 +460,8 @@ final class SystemBrightnessController {
|
|||
|
||||
func stop() {
|
||||
onBrightnessChange = nil
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
brightnessAnimationTimer = nil
|
||||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
|
|
@ -294,17 +470,8 @@ final class SystemBrightnessController {
|
|||
|
||||
func setBrightness(_ value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
if setBrightnessViaDisplayServices(clamped) {
|
||||
notifyCurrentBrightness()
|
||||
return
|
||||
}
|
||||
guard let service = displayService() else { return }
|
||||
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||
IOObjectRelease(service)
|
||||
if status == kIOReturnSuccess {
|
||||
notifyCurrentBrightness()
|
||||
} else {
|
||||
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
||||
DispatchQueue.main.async {
|
||||
self.beginBrightnessAnimation(to: clamped)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,10 +491,77 @@ final class SystemBrightnessController {
|
|||
|
||||
private func notifyCurrentBrightness() {
|
||||
let brightness = currentBrightness
|
||||
DispatchQueue.main.async {
|
||||
self.onBrightnessChange?(brightness)
|
||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness])
|
||||
emitBrightnessChange(value: brightness)
|
||||
}
|
||||
|
||||
private func beginBrightnessAnimation(to target: Float) {
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
|
||||
let start = lastEmittedBrightness
|
||||
if abs(start - target) <= 0.0005 {
|
||||
applyBrightness(target)
|
||||
emitBrightnessChange(value: target)
|
||||
return
|
||||
}
|
||||
|
||||
brightnessAnimationStart = start
|
||||
brightnessAnimationTarget = target
|
||||
brightnessAnimationStartDate = Date()
|
||||
|
||||
let interval = brightnessAnimationDuration / Double(brightnessAnimationSteps)
|
||||
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in
|
||||
guard let self else { return }
|
||||
guard let startDate = self.brightnessAnimationStartDate else {
|
||||
timer.invalidate()
|
||||
self.brightnessAnimationTimer = nil
|
||||
return
|
||||
}
|
||||
let elapsed = Date().timeIntervalSince(startDate)
|
||||
let progress = min(elapsed / self.brightnessAnimationDuration, 1)
|
||||
let eased = self.ease(progress)
|
||||
let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased)
|
||||
self.applyBrightness(value)
|
||||
self.emitBrightnessChange(value: value)
|
||||
if progress >= 1 {
|
||||
timer.invalidate()
|
||||
self.brightnessAnimationTimer = nil
|
||||
}
|
||||
}
|
||||
brightnessAnimationTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
timer.fire()
|
||||
}
|
||||
|
||||
private func applyBrightness(_ value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
if setBrightnessViaDisplayServices(clamped) {
|
||||
return
|
||||
}
|
||||
guard let service = displayService() else { return }
|
||||
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||
IOObjectRelease(service)
|
||||
if status != kIOReturnSuccess {
|
||||
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func emitBrightnessChange(value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
lastEmittedBrightness = clamped
|
||||
let dispatchBlock = {
|
||||
self.onBrightnessChange?(clamped)
|
||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": clamped])
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
dispatchBlock()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: dispatchBlock)
|
||||
}
|
||||
}
|
||||
|
||||
private func ease(_ t: Double) -> Double {
|
||||
let clamped = min(max(t, 0), 1)
|
||||
return 1 - pow(1 - clamped, 3)
|
||||
}
|
||||
|
||||
private func displayService() -> io_service_t? {
|
||||
|
|
@ -386,6 +620,7 @@ final class SystemBrightnessController {
|
|||
}
|
||||
|
||||
deinit {
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,10 +334,14 @@ extension Defaults.Keys {
|
|||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
|
||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
|
||||
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
|
||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
||||
|
|
@ -362,6 +366,7 @@ extension Defaults.Keys {
|
|||
static let enableGradient = Key<Bool>("enableGradient", default: false)
|
||||
static let systemEventIndicatorShadow = Key<Bool>("systemEventIndicatorShadow", default: false)
|
||||
static let systemEventIndicatorUseAccent = Key<Bool>("systemEventIndicatorUseAccent", default: false)
|
||||
static let showProgressPercentages = Key<Bool>("showProgressPercentages", default: false)
|
||||
|
||||
// MARK: Shelf
|
||||
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
||||
|
|
@ -386,6 +391,9 @@ extension Defaults.Keys {
|
|||
static let useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
|
||||
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
|
||||
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
|
||||
static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true)
|
||||
static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: true)
|
||||
static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: true)
|
||||
|
||||
// MARK: Stats Feature
|
||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
@Published var isClipboardPopoverActive: Bool = false
|
||||
@Published var isColorPickerPopoverActive: Bool = false
|
||||
@Published var isStatsPopoverActive: Bool = false
|
||||
@Published var isMediaOutputPopoverActive: Bool = false
|
||||
@Published var shouldRecheckHover: Bool = false
|
||||
|
||||
let webcamManager = WebcamManager.shared
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 133 B |
31
ReadMe.md
31
ReadMe.md
|
|
@ -2,7 +2,26 @@
|
|||
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="120">
|
||||
</p>
|
||||
|
||||
> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||
<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>
|
||||
</p>
|
||||
|
||||
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||
|
||||
# Atoll
|
||||
|
||||
|
|
@ -57,6 +76,16 @@ The application provides a comprehensive interface for monitoring system perform
|
|||
<img src=".github/assets/atoll-clipboard.png" alt="Clipboard manager" width="320">
|
||||
</p>
|
||||
|
||||
### LockScreen Widgets
|
||||
- **Weather**
|
||||
- **Media Player**
|
||||
- **Device Charging status**
|
||||
- **Bluetooth Peripheral Battery status**
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/lockscreen.gif" alt="Lockscreen demo" width="720">
|
||||
</p>
|
||||
|
||||
### Layout and customisation
|
||||
- **Minimalistic mode** reducing the notch interface width from 640px to 420px, preserving essential music controls, HUDs, and timer functionality while removing secondary features for users prioritising visual simplicity.
|
||||
- **Per-feature toggles** enabling granular control over music controls, individual stats monitoring categories (CPU, memory, GPU, network, disk), clipboard manager display, colour picker, screen assistant, timers, and other modules.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue