Merge branch 'main' into main

This commit is contained in:
Dan Querido 2025-11-05 20:52:27 +00:00 • committed by GitHub
commit 3b53358842
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 8310 additions and 1239 deletions

2
.gitattributes vendored Normal file
View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 133 B

Before After
Before After

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

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

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

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bec19433c1bddad8bc9b86b2f61f9324a109e3469753625643531d6bbb3598f
size 1189001

3
.github/assets/timer.mov vendored Normal file
View file

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

2
.gitignore vendored
View file

@ -152,3 +152,5 @@ borrowing/
MoveToSky.xcodeproj
/MoveToSky/
mew-notch/
/References/

71
CODE_OF_CONDUCT.md Normal file
View 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

97
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,97 @@
<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)
- [Code Review process](#code-review-process)
- [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.
## Code review process
- All pull requests require review from project maintainers before merging.
- Automated testing must pass via continuous integration workflows.
- Changes should not significantly decrease test coverage without justification.
- Breaking changes require major version updates following semantic versioning.
## 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!

View file

@ -59,7 +59,7 @@
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -144,7 +144,7 @@
F54406E82CD4987A00C7AFD9 /* Products */ = {
isa = PBXGroup;
children = (
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */,
F54406E72CD4987A00C7AFD9 /* Atoll.app */,
);
name = Products;
sourceTree = "<group>";
@ -182,7 +182,7 @@
7E61181E2E24071E0050006C /* SkyLightWindow */,
);
productName = DynamicIsland;
productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */;
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@ -221,6 +221,7 @@
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
@ -401,20 +402,26 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = "1.0.1-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
MARKETING_VERSION = "1.0.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -449,20 +456,26 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = "1.0.1-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
MARKETING_VERSION = "1.0.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -529,6 +542,14 @@
minimumVersion = 2.6.4;
};
};
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/open-meteo/open-meteo";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.4.16;
};
};
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";

View file

@ -1,5 +1,5 @@
{
"originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944",
"originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce",
"pins" : [
{
"identity" : "defaults",
@ -55,6 +55,15 @@
"version" : "0.1.0"
}
},
{
"identity" : "open-meteo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-meteo/open-meteo",
"state" : {
"revision" : "f18ccb781804c444b87e6175a4b62ecfbe0212dc",
"version" : "1.4.16"
}
},
{
"identity" : "pow",
"kind" : "remoteSourceControl",

View file

@ -16,7 +16,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -1,61 +1,61 @@
{
"images" : [
{
"filename" : "upscaled-7.png",
"filename" : "Atoll_1024 (6).png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "upscaled-6 1.png",
"filename" : "Atoll_1024 (5) 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "upscaled-6.png",
"filename" : "Atoll_1024 (5).png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "upscaled-5.png",
"filename" : "Atoll_1024 (4).png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "upscaled-4.png",
"filename" : "Atoll_1024 (3).png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "upscaled-3 1.png",
"filename" : "Atoll_1024 (2) 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "upscaled-3.png",
"filename" : "Atoll_1024 (2).png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "upscaled-2.png",
"filename" : "Atoll_1024 (1) 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "notch-stage-icon2 9.png",
"filename" : "Atoll_1024 (1).png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "notch-stage-icon2 10.png",
"filename" : "Atoll_1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

View file

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "ChatGPT Image Aug 13, 2025, 11_50_32 PM.png",
"filename" : "Atoll.png",
"idiom" : "universal"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View file

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png",
"filename" : "Atoll_1024.png",
"idiom" : "universal"
}
],

View file

@ -200,14 +200,14 @@ struct ContentView: View {
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive {
vm.close()
}
}
@ -215,7 +215,7 @@ struct ContentView: View {
.onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering {
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering {
vm.close()
}
}
@ -320,10 +320,9 @@ struct ContentView: View {
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
SettingsLink(label: {
Text("Settings")
})
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
Button("Settings") {
SettingsWindowController.shared.showWindow()
}
// Button("Edit") { // Doesnt work....
// let dn = DynamicNotch(content: EditPanelView())
// dn.toggle()
@ -404,7 +403,7 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing)
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) {
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition(.opacity)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
@ -429,7 +428,7 @@ struct ContentView: View {
}
if coordinator.sneakPeek.show {
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] {
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
//
})
@ -631,11 +630,6 @@ struct ContentView: View {
// MARK: - Private Methods
private func doOpen() {
// Switch to timer tab if timer is active
if timerManager.isTimerActive {
coordinator.currentView = .timer
}
withAnimation(.bouncy.speed(1.2)) {
vm.open()
}
@ -751,10 +745,12 @@ struct ContentView: View {
// Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool {
return vm.isBatteryPopoverActive ||
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive
return vm.isBatteryPopoverActive ||
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive ||
vm.isTimerPopoverActive ||
vm.isMediaOutputPopoverActive
}
// Helper to prevent rapid haptic feedback

View file

@ -34,7 +34,6 @@ struct DynamicNotchApp: App {
Button("Settings") {
SettingsWindowController.shared.showWindow()
}
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
if false {
Button("Activate License") {
openWindow(id: "activation")
@ -70,6 +69,14 @@ struct DynamicNotchApp: App {
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
var commands: some Commands {
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
SettingsWindowController.shared.showWindow()
}
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
@ -355,8 +362,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
coordinator.setupWorkersNotificationObservers()
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
LockScreenManager.shared.configure(viewModel: vm)
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
LockScreenManager.shared.configure(viewModel: vm)
// Migrate legacy progress bar settings
Defaults.Keys.migrateProgressBarStyle()
@ -405,6 +412,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 +488,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 +499,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 +536,72 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in
guard let self = self else { return }
// Only execute if shortcuts are enabled
_ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) {
guard Defaults[.enableShortcuts] else { return }
// Only start timer if the timer feature is enabled
guard Defaults[.enableTimerFeature] else { return }
// Start a 5-minute demo timer
TimerManager.shared.startDemoTimer(duration: 300)
}
KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
_ = KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
guard let self = self else { return }
// Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
// Only open clipboard if the feature is enabled
guard Defaults[.enableClipboardManager] else { return }
// Start clipboard monitoring if not already running
if !ClipboardManager.shared.isMonitoring {
ClipboardManager.shared.startMonitoring()
}
// Handle keyboard shortcut based on display mode
switch Defaults[.clipboardDisplayMode] {
case .panel:
ClipboardPanelManager.shared.toggleClipboardPanel()
case .popover:
// For popover mode, first ensure notch is open, then toggle popover
// If notch is closed, open it first
if self.vm.notchState == .closed {
self.vm.open()
// Wait a moment for the notch to open, then show popover
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
}
} else {
// Notch is already open, toggle popover immediately
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
}
}
}
KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in
guard let self = self else { return }
// Only execute if shortcuts are enabled
_ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) {
guard Defaults[.enableShortcuts] else { return }
// Only open color picker panel if the feature is enabled
guard Defaults[.enableColorPickerFeature] else { return }
// Toggle color picker panel
ColorPickerPanelManager.shared.toggleColorPickerPanel()
}
KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
_ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
guard let self = self else { return }
// Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
// Only open screen assistant if the feature is enabled
guard Defaults[.enableScreenAssistant] else { return }
// Handle keyboard shortcut based on display mode
switch Defaults[.screenAssistantDisplayMode] {
case .panel:
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
case .popover:
// For popover mode, first ensure notch is open, then toggle popover
// If notch is closed, open it first
if self.vm.notchState == .closed {
self.vm.open()
// Wait a moment for the notch to open, then show popover
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
} else {
// Notch is already open, toggle popover immediately
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
}
}
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
guard let self = self else { return }
KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
// Only execute if shortcuts are enabled
_ = KeyboardShortcuts.onKeyDown(for: .statsPanel) {
guard Defaults[.enableShortcuts] else { return }
// Only open stats panel if both stats feature and panel are enabled
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
// Start stats monitoring if not already running
if !StatsManager.shared.isMonitoring {
StatsManager.shared.startMonitoring()
}
// Toggle stats panel
StatsPanelManager.shared.toggleStatsPanel()
}

View file

@ -7,14 +7,6 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>LSUIElement</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.</string>
<key>NSLocationUsageDescription</key>
<string>Dynamic Island uses your location to display accurate lock screen weather.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Dynamic Island uses your location to display accurate lock screen weather.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string>
<key>SUEnableDownloaderService</key>

File diff suppressed because it is too large Load diff

View file

@ -9,16 +9,31 @@
import SwiftUI
import Defaults
enum ColorCodingMode {
case volume
case battery
func intensity(for value: CGFloat) -> CGFloat {
let clamped = min(max(value, 0), 1)
switch self {
case .volume:
return clamped
case .battery:
return 1 - clamped
}
}
}
struct ColorCodedProgressBar: View {
let value: CGFloat // 0.0 to 1.0
let reversed: Bool // If true: green at low, red at high (for volume)
let mode: ColorCodingMode
let width: CGFloat
let height: CGFloat
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
init(value: CGFloat, mode: ColorCodingMode = .battery, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
self.reversed = reversed
self.mode = mode
self.width = width
self.height = height
self.smoothGradient = smoothGradient
@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View {
// Filled track with color gradient or solid color
RoundedRectangle(cornerRadius: height / 2)
.fill(smoothGradient ?
AnyShapeStyle(LinearGradient(
gradient: gradientForValue,
startPoint: .leading,
endPoint: .trailing
)) :
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
)
.fill(fillStyle)
.frame(width: width * value, height: height)
}
}
.frame(width: width, height: height)
}
/// Generates a gradient based on the current value
private var gradientForValue: Gradient {
let color = colorForPercentage(value, reversed: reversed)
// Create a gradient that transitions smoothly
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
let endColor = color
return Gradient(colors: [startColor, endColor])
}
/// Calculates discrete color for a given percentage (no smooth transitions)
/// - Parameters:
/// - percentage: Value from 0.0 to 1.0
/// - reversed: If true, reverses the color scale (green at low, red at high)
/// - Returns: Discrete color (red, yellow, or green only)
private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
let normalized = reversed ? (1.0 - percentage) : percentage
switch normalized {
case 0.0..<0.33:
// Red zone (critical/low)
return Color(red: 1.0, green: 0.23, blue: 0.19)
case 0.33..<0.67:
// Yellow zone (medium)
return Color(red: 1.0, green: 0.8, blue: 0.0)
default:
// Green zone (healthy/high)
return Color(red: 0.2, green: 0.78, blue: 0.35)
}
}
/// Calculates color for a given percentage with smooth transitions
/// - Parameters:
/// - percentage: Value from 0.0 to 1.0
/// - reversed: If true, reverses the color scale (green at low, red at high)
/// - Returns: Color with smooth gradient transitions
private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
let normalized = reversed ? (1.0 - percentage) : percentage
switch normalized {
case 0.0..<0.2:
// Pure Red zone (critical/low)
return Color(red: 1.0, green: 0.23, blue: 0.19)
case 0.2..<0.35:
// Red → Orange transition
let t = (normalized - 0.2) / 0.15
let red: CGFloat = 1.0
let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
let blue: CGFloat = 0.19
return Color(red: red, green: green, blue: blue)
case 0.35..<0.5:
// Orange → Yellow transition
let t = (normalized - 0.35) / 0.15
let red: CGFloat = 1.0
let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
let blue: CGFloat = 0.19
return Color(red: red, green: green, blue: blue)
case 0.5..<0.65:
// Yellow → Yellow-Green transition
let t = (normalized - 0.5) / 0.15
let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
let green: CGFloat = 1.0
let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
return Color(red: red, green: green, blue: blue)
case 0.65..<0.8:
// Yellow-Green → Green transition
let t = (normalized - 0.65) / 0.15
let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
return Color(red: red, green: green, blue: blue)
default:
// Pure Green zone (healthy/high)
return Color(red: 0.2, green: 0.78, blue: 0.35)
private var fillStyle: AnyShapeStyle {
let intensity = mode.intensity(for: value)
if smoothGradient {
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
} else {
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
}
}
}
@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View {
extension ColorCodedProgressBar {
/// Creates a battery-style progress bar (green at high, red at low)
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
ColorCodedProgressBar(value: value, mode: .battery, width: width, height: height, smoothGradient: smoothGradient)
}
/// Creates a volume-style progress bar (red at high, green at low)
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
ColorCodedProgressBar(value: value, mode: .volume, width: width, height: height, smoothGradient: smoothGradient)
}
/// Shared helper for other progress bar implementations that need the same palette.
static func shapeStyle(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> AnyShapeStyle {
let intensity = mode.intensity(for: min(max(value, 0), 1))
if smoothGradient {
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
}
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
}
/// Returns the palette color that matches the configured thresholds. Useful for non-linear indicators.
static func paletteColor(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> Color {
let normalized = min(max(value, 0), 1)
let intensity = mode.intensity(for: normalized)
return ColorCodedPalette.color(for: intensity, smooth: smoothGradient)
}
}
// MARK: - Palette Helpers
private enum ColorCodedPalette {
private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
static func color(for intensity: CGFloat, smooth: Bool) -> Color {
if smooth {
return smoothColor(for: intensity)
}
return discreteColor(for: intensity)
}
static func discreteColor(for intensity: CGFloat) -> Color {
let clamped = min(max(intensity, 0), 1)
switch clamped {
case ..<0.6:
return color(from: greenComponents)
case ..<0.85:
return color(from: yellowComponents)
default:
return color(from: redComponents)
}
}
static func gradient(for intensity: CGFloat) -> LinearGradient {
let endColor = smoothColor(for: intensity)
let startColor = smoothColor(for: max(intensity - 0.15, 0))
return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing)
}
private static func smoothColor(for intensity: CGFloat) -> Color {
let clamped = min(max(intensity, 0), 1)
switch clamped {
case ..<0.6:
// Stay green within the comfort zone
return color(from: greenComponents)
case ..<0.85:
let t = (clamped - 0.6) / 0.25
return blend(from: greenComponents, to: yellowComponents, fraction: t)
default:
let t = (clamped - 0.85) / 0.15
return blend(from: yellowComponents, to: redComponents, fraction: t)
}
}
private static func blend(from start: (CGFloat, CGFloat, CGFloat), to end: (CGFloat, CGFloat, CGFloat), fraction: CGFloat) -> Color {
let t = min(max(fraction, 0), 1)
let red = lerp(start.0, end.0, t)
let green = lerp(start.1, end.1, t)
let blue = lerp(start.2, end.2, t)
return color(from: (red, green, blue))
}
private static func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
a + (b - a) * t
}
private static func color(from components: (CGFloat, CGFloat, CGFloat)) -> Color {
Color(red: Double(components.0), green: Double(components.1), blue: Double(components.2))
}
}

View file

@ -20,12 +20,52 @@ 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 ? 140 : 88
}
return 100
}()
let infoWidth: CGFloat = {
var width = baseInfoWidth + gestureProgress / 2
if !hoverAnimation { width -= 8 }
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88
return max(width, minimum)
}()
let baseTrailingWidth: CGFloat = {
if type == .bluetoothAudio {
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? 118 : 88
}
if useCircularIndicator {
return showBluetoothBatteryPercentageText ? 120 : 96
} else {
return showBluetoothBatteryPercentageText ? 136 : 108
}
}
return 100
}()
let trailingWidth: CGFloat = {
var width = baseTrailingWidth + gestureProgress / 2
if !hoverAnimation { width -= 8 }
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90
return max(width, minimum)
}()
return HStack {
HStack(spacing: 5) {
Group {
switch (type) {
@ -77,22 +117,15 @@ struct InlineHUD: View {
// Use marquee text for device names to handle long names
if type == .bluetoothAudio {
MarqueeText(
$displayName,
font: .system(size: 13, weight: .medium),
nsFont: .body,
textColor: .white,
minDuration: 0.5,
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
)
.onAppear {
displayName = Type2Name(type)
}
.onChange(of: type) { _, _ in
displayName = Type2Name(type)
}
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
displayName = Type2Name(type)
if showBluetoothDeviceNameMarquee {
MarqueeText(
$displayName,
font: .system(size: 13, weight: .medium),
nsFont: .body,
textColor: .white,
minDuration: 0.2,
frameWidth: infoWidth
)
}
} else {
Text(Type2Name(type))
@ -103,7 +136,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,60 +160,145 @@ struct InlineHUD: View {
.frame(maxWidth: .infinity, alignment: .trailing)
.contentTransition(.interpolate)
} else if (type == .bluetoothAudio) {
// Bluetooth device battery display
HStack(spacing: 4) {
if value > 0 {
// Show color-coded battery bar if battery info available
// Color-coding disabled for segmented or hierarchical mode
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
if hasBatteryLevel {
HStack(spacing: useCircularIndicator ? 8 : 6) {
if useCircularIndicator {
CircularBatteryIndicator(
value: value,
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
smoothGradient: useSmoothColorGradient
)
.allowsHitTesting(false)
} else {
DraggableProgressBar(value: .constant(value))
.frame(width: 60)
LinearBatteryIndicator(
value: value,
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
smoothGradient: useSmoothColorGradient
)
.allowsHitTesting(false)
}
Text("\(Int(value * 100))%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white)
.lineLimit(1)
if showBluetoothBatteryPercentageText {
Text("\(Int(value * 100))%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
} else {
// Volume and brightness displays
Group {
if type == .volume {
Group {
if value.isZero {
Text("muted")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.lineLimit(1)
.allowsTightening(true)
.multilineTextAlignment(.trailing)
.contentTransition(.numericText())
} else {
HStack(spacing: 6) {
DraggableProgressBar(value: $value, colorMode: .volume)
PercentageLabel(value: value, isVisible: showProgressPercentages)
}
.transition(.opacity.combined(with: .scale))
}
}
.animation(.smooth(duration: 0.2), value: value.isZero)
} else {
// No battery info available
Text("Connected")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.lineLimit(1)
HStack(spacing: 6) {
DraggableProgressBar(value: $value)
PercentageLabel(value: value, isVisible: showProgressPercentages)
}
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
// Volume and brightness displays
HStack {
// Color-coding only available in gradient mode, not hierarchical or segmented
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
// Color-coded volume (reversed: red at high, green at low)
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
} else {
// Default draggable progress bar
DraggableProgressBar(value: $value)
}
if (type == .volume && value.isZero) {
Text("muted")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.lineLimit(1)
.allowsTightening(true)
.multilineTextAlignment(.trailing)
}
}
}
}
.padding(.trailing, 4)
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
}
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
.onAppear {
displayName = Type2Name(type)
}
.onChange(of: type) { _, _ in
displayName = Type2Name(type)
}
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
displayName = Type2Name(type)
}
}
private struct CircularBatteryIndicator: View {
let value: CGFloat
let useColorCoding: Bool
let smoothGradient: Bool
private var clampedValue: CGFloat {
min(max(value, 0), 1)
}
private var indicatorColor: Color {
if useColorCoding {
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
}
return .white
}
var body: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.18), lineWidth: 2.6)
Circle()
.trim(from: 0, to: max(clampedValue, 0.015))
.rotation(.degrees(-90))
.stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round))
}
.frame(width: 22, height: 22)
.animation(.smooth(duration: 0.18), value: clampedValue)
}
}
private struct LinearBatteryIndicator: View {
let value: CGFloat
let useColorCoding: Bool
let smoothGradient: Bool
private let trackWidth: CGFloat = 54
private let trackHeight: CGFloat = 6
private var clampedValue: CGFloat {
min(max(value, 0), 1)
}
private var fillColor: Color {
if useColorCoding {
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
}
return .white
}
var body: some View {
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.18))
.frame(width: trackWidth, height: trackHeight)
Capsule()
.fill(fillColor)
.frame(width: trackWidth * clampedValue, height: trackHeight)
}
.frame(width: trackWidth, height: trackHeight)
.animation(.smooth(duration: 0.18), value: clampedValue)
}
}
func SpeakerSymbol(_ value: CGFloat) -> String {

View file

@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View {
@Binding var icon: String
let showSlider: Bool = false
var sendEventBack: (CGFloat) -> Void
@Default(.showProgressPercentages) private var showProgressPercentages
var body: some View {
HStack(spacing: 14) {
@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View {
default:
EmptyView()
}
if (eventType != .mic) {
DraggableProgressBar(value: $value)
} else {
Text("Mic \(value > 0 ? "unmuted" : "muted")")
switch eventType {
case .mic:
Text(value > 0 ? "Mic unmuted" : "Mic muted")
.foregroundStyle(.gray)
.lineLimit(1)
.allowsTightening(true)
.contentTransition(.numericText())
case .volume:
VolumeProgressSection(value: $value, showPercentages: showProgressPercentages)
case .brightness:
ProgressSection(value: $value, showPercentages: showProgressPercentages)
case .backlight:
ProgressSection(value: $value, showPercentages: showProgressPercentages)
default:
ProgressSection(value: $value, showPercentages: showProgressPercentages)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -88,18 +97,106 @@ struct SystemEventIndicatorModifier: View {
}
}
struct ProgressSection: View {
@Binding var value: CGFloat
var showPercentages: Bool
var colorMode: ProgressColorMode? = nil
var body: some View {
HStack(spacing: 8) {
DraggableProgressBar(value: $value, colorMode: colorMode)
PercentageLabel(value: value, isVisible: showPercentages)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct VolumeProgressSection: View {
@Binding var value: CGFloat
var showPercentages: Bool
var body: some View {
Group {
if value.isZero {
Text("muted")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.contentTransition(.numericText())
} else {
ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume)
.transition(.opacity.combined(with: .scale))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(.smooth(duration: 0.2), value: value.isZero)
}
}
struct PercentageLabel: View {
let value: CGFloat
let isVisible: Bool
@Default(.progressBarStyle) private var progressBarStyle
var body: some View {
Group {
if shouldShow {
Text(formattedValue)
.font(.caption)
.fontWeight(.semibold)
.monospacedDigit()
.foregroundStyle(.white.opacity(0.9))
.contentTransition(.numericText())
.frame(width: 28, alignment: .trailing)
}
}
}
private var shouldShow: Bool {
isVisible && progressBarStyle != .segmented
}
private var formattedValue: String {
let raw = max(0, min(100, Int(round(value * 100))))
return String(format: "%3d", raw)
}
}
enum ProgressColorMode {
case volume
case battery
}
private extension ProgressColorMode {
var colorCodingMode: ColorCodingMode {
switch self {
case .volume:
return .volume
case .battery:
return .battery
}
}
}
struct DraggableProgressBar: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@Binding var value: CGFloat
var colorMode: ProgressColorMode? = nil
@State private var isDragging = false
@State private var dragOffset: CGFloat = 0
@Default(.progressBarStyle) private var progressBarStyle
@Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay
@Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay
@Default(.useSmoothColorGradient) private var useSmoothColorGradient
@Default(.systemEventIndicatorUseAccent) private var useAccentColor
@Default(.systemEventIndicatorShadow) private var useShadow
@Default(.inlineHUD) private var inlineHUD
var body: some View {
VStack {
GeometryReader { geo in
Group {
if Defaults[.progressBarStyle] == .segmented {
if progressBarStyle == .segmented {
// Segmented progress bar - completely different layout
SegmentedProgressContent(value: value, geometry: geo)
} else {
@ -107,23 +204,8 @@ struct DraggableProgressBar: View {
ZStack(alignment: .leading) {
Capsule()
.fill(.tertiary)
Group {
switch Defaults[.progressBarStyle] {
case .gradient:
Capsule()
.fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
case .hierarchical:
Capsule()
.fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
case .segmented:
EmptyView() // This case won't be reached due to the outer if condition
}
}
.opacity(value.isZero ? 0 : 1)
progressFill(width: geo.size.width)
.opacity(value.isZero ? 0 : 1)
}
}
}
@ -142,7 +224,7 @@ struct DraggableProgressBar: View {
}
)
}
.frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6)
.frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6))
}
}
@ -152,6 +234,59 @@ struct DraggableProgressBar: View {
value = max(0, min(newValue, 1))
}
private func progressFill(width: CGFloat) -> some View {
let clampedWidth = max(0, min(width * value, width))
let accentColor = Defaults[.accentColor]
let shadowColor: Color = {
guard useShadow else { return .clear }
if useAccentColor {
return accentColor.ensureMinimumBrightness(factor: 0.7)
}
return .white
}()
return Group {
if shouldUseColorCoding, let mode = colorMode?.colorCodingMode {
Capsule()
.fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient))
.frame(width: clampedWidth)
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
} else {
switch progressBarStyle {
case .gradient:
Capsule()
.fill(LinearGradient(
colors: useAccentColor
? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)]
: [.white, .white.opacity(0.2)],
startPoint: .trailing,
endPoint: .leading
))
.frame(width: clampedWidth)
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
case .hierarchical:
Capsule()
.fill(useAccentColor ? accentColor : .white)
.frame(width: clampedWidth)
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
case .segmented:
EmptyView()
}
}
}
}
private var shouldUseColorCoding: Bool {
guard let colorMode else { return false }
guard progressBarStyle != .segmented else { return false }
switch colorMode {
case .volume:
return useColorCodedVolumeDisplay
case .battery:
return useColorCodedBatteryDisplay
}
}
}
struct SegmentedProgressContent: View {
@ -164,8 +299,8 @@ struct SegmentedProgressContent: View {
var body: some View {
let spacing: CGFloat = 1.5
let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
let barWidth = max(3.0, min(6.0, computed))
let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
let barWidth = max(3.0, computed)
let activeCount = Int(round(value * CGFloat(segmentCount)))
HStack(spacing: spacing) {

View file

@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View {
static let expandedSize = CGSize(width: 720, height: 340)
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@State private var sliderValue: Double = 0
@State private var dragging: Bool = false
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var collapseWorkItem: DispatchWorkItem?
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
@Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) var showMediaOutputControl
private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52
@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View {
private let expandedAlbumArtCornerRadius: CGFloat = 60
private let expandedContentSpacing: CGFloat = 40
private let collapseTimeout: TimeInterval = 5
private let collapsedSliderExtraHeight: CGFloat = 72
private let expandedSliderExtraHeight: CGFloat = 88
private var currentSize: CGSize {
isExpanded ? Self.expandedSize : Self.collapsedSize
let base = isExpanded ? Self.expandedSize : Self.collapsedSize
return CGSize(width: base.width, height: base.height + sliderExtraHeight)
}
private var panelCornerRadius: CGFloat {
@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View {
panelCore
.frame(width: currentSize.width, height: currentSize.height)
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
.onAppear {
sliderValue = musicManager.elapsedTime
isActive = true
logPanelAppearance()
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
animated: false
)
routeManager.refreshDevices()
}
.onDisappear {
isActive = false
cancelCollapseTimer()
isVolumeSliderVisible = false
}
.onChange(of: isExpanded) { _, expanded in
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
LockScreenPanelManager.shared.updatePanelSize(
expanded: expanded,
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
)
}
.onChange(of: showMediaOutputControl) { _, enabled in
if !enabled {
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
isVolumeSliderVisible = false
}
}
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
)
}
}
@ -332,8 +360,22 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20
return HStack(spacing: spacing) {
// Always show shuffle on lock screen
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
controlsRow(alignment: alignment, spacing: spacing)
if shouldShowVolumeSlider {
volumeSlider
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
}
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
HStack(spacing: spacing) {
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
musicManager.toggleShuffle()
}
@ -348,13 +390,15 @@ struct LockScreenMusicPanel: View {
musicManager.nextTrack()
}
// Always show repeat on lock screen
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
if showMediaOutputControl {
mediaOutputControlButton
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
}
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
}
private var playPauseButton: some View {
@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle())
}
private var mediaOutputControlButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
return Button(action: toggleVolumeSlider) {
Image(systemName: mediaOutputIcon)
.font(.system(size: iconSize, weight: .medium))
.foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8))
.frame(width: frameSize, height: frameSize)
.contentShape(Rectangle())
}
.accessibilityLabel("Media output")
.buttonStyle(PlainButtonStyle())
}
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"
@ -399,6 +458,87 @@ struct LockScreenMusicPanel: View {
}
}
private var mediaOutputIcon: String {
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
}
private var volumeSlider: some View {
HStack(spacing: 14) {
Image(systemName: volumeIconName)
.font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
.foregroundColor(.white.opacity(0.8))
Slider(
value: Binding(
get: { Double(volumeModel.level) },
set: { newValue in
registerInteraction()
volumeModel.setVolume(Float(newValue))
}
),
in: 0 ... 1
)
.tint(sliderColor)
Text(volumePercentage)
.font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 48, alignment: .trailing)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
.fill(Color.white.opacity(0.08))
)
}
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
private var sliderExtraHeight: CGFloat {
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
}
private var volumeIconName: String {
if volumeModel.isMuted || volumeModel.level <= 0.001 {
return "speaker.slash.fill"
} else if volumeModel.level < 0.33 {
return "speaker.wave.1.fill"
} else if volumeModel.level < 0.66 {
return "speaker.wave.2.fill"
}
return "speaker.wave.3.fill"
}
private var volumePercentage: String {
"\(Int(round(volumeModel.level * 100)))%"
}
private func toggleVolumeSlider() {
registerInteraction()
let newState = !isVolumeSliderVisible
if newState {
routeManager.refreshDevices()
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
isVolumeSliderVisible = newState
}
let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
LockScreenPanelManager.shared.updatePanelSize(
expanded: isExpanded,
additionalHeight: targetHeight
)
}
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
guard visible else { return 0 }
return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
}
@ViewBuilder
private var panelBackground: some View {
if enableBlur {

View file

@ -1,47 +1,593 @@
import SwiftUI
struct LockScreenWeatherWidget: View {
let snapshot: LockScreenWeatherSnapshot
let snapshot: LockScreenWeatherSnapshot
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))
}
}
.foregroundStyle(Color.white.opacity(0.65))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.clear)
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
private let secondaryLabelColor = Color.white.opacity(0.7)
private var accessibilityLabel: String {
if let locationName = snapshot.locationName, !locationName.isEmpty {
return String(
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
snapshot.description,
snapshot.temperatureText,
locationName
)
}
private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var gaugeDiameter: CGFloat { 64 }
private var topPadding: CGFloat { isInline ? 6 : 22 }
private var bottomPadding: CGFloat { isInline ? 6 : 10 }
return String(
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
snapshot.description,
snapshot.temperatureText
)
}
private var monochromeGaugeTint: Color {
Color.white.opacity(0.9)
}
var body: some View {
HStack(alignment: stackAlignment, spacing: stackSpacing) {
if let charging = snapshot.charging {
chargingSegment(for: charging)
}
if !isInline, let battery = snapshot.battery {
batterySegment(for: battery)
}
if let bluetooth = snapshot.bluetooth {
bluetoothSegment(for: bluetooth)
}
if let airQuality = snapshot.airQuality {
airQualitySegment(for: airQuality)
}
weatherSegment
if shouldShowLocation {
locationSegment
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(Color.white.opacity(0.65))
.padding(.horizontal, 10)
.padding(.top, topPadding)
.padding(.bottom, bottomPadding)
.background(Color.clear)
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
@ViewBuilder
private var weatherSegment: some View {
switch snapshot.widgetStyle {
case .inline:
inlineWeatherSegment
case .circular:
circularWeatherSegment
}
}
private var inlineWeatherSegment: some View {
HStack(alignment: .center, spacing: 6) {
Image(systemName: snapshot.symbolName)
.font(.system(size: 26, weight: .medium))
.symbolRenderingMode(.hierarchical)
Text(snapshot.temperatureText)
.font(inlinePrimaryFont)
.kerning(-0.3)
.lineLimit(1)
.minimumScaleFactor(0.9)
.layoutPriority(2)
}
}
@ViewBuilder
private var circularWeatherSegment: some View {
if let info = snapshot.temperatureInfo {
temperatureGauge(for: info)
} else {
inlineWeatherSegment
}
}
@ViewBuilder
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
inlineChargingSegment(for: info)
case .circular:
circularChargingSegment(for: info)
}
}
@ViewBuilder
private func batterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
EmptyView()
case .circular:
circularBatterySegment(for: info)
}
}
private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let iconName = chargingIconName(for: info) {
Image(systemName: iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
}
Text(inlineChargingLabel(for: info))
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
@ViewBuilder
private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let rawLevel = info.batteryLevel {
let level = clampedBatteryLevel(rawLevel)
VStack(spacing: 6) {
Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
chargingGlyph(for: info)
} minimumValueLabel: {
Text("0")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
} maximumValueLabel: {
Text("100")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(chargingDetailLabel(for: info))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
.lineLimit(1)
}
.layoutPriority(1)
} else {
inlineChargingSegment(for: info)
}
}
private func circularBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
let level = clampedBatteryLevel(info.batteryLevel)
return VStack(spacing: 6) {
Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: batteryIconName(for: level))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("\(level)%")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.layoutPriority(1)
}
@ViewBuilder
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
inlineBluetoothSegment(for: info)
case .circular:
circularBluetoothSegment(for: info)
}
}
private func inlineBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: info.iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
Text(bluetoothPercentageText(for: info.batteryLevel))
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
let clamped = clampedBatteryLevel(info.batteryLevel)
return VStack(spacing: 6) {
Gauge(value: Double(clamped), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: info.iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(bluetoothTint(for: clamped))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(bluetoothPercentageText(for: info.batteryLevel))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.layoutPriority(1)
}
@ViewBuilder
private func airQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
inlineAirQualitySegment(for: info)
case .circular:
circularAirQualitySegment(for: info)
}
}
private func inlineAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "wind")
.font(.system(size: 18, weight: .semibold))
.symbolRenderingMode(.hierarchical)
inlineComposite(primary: "AQI \(info.index)", secondary: info.category.displayName)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
VStack(spacing: 6) {
Gauge(value: Double(info.index), in: 0...500) {
EmptyView()
} currentValueLabel: {
Text("\(info.index)")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(Color.white)
}
.gaugeStyle(.accessoryCircular)
.tint(aqiTint(for: info.category))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("AQI · \(info.category.displayName)")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
.lineLimit(1)
}
.layoutPriority(1)
}
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
let range = temperatureRange(for: info)
return VStack(spacing: 6) {
Gauge(value: info.current, in: range) {
EmptyView()
} currentValueLabel: {
temperatureCenterLabel(for: info)
}
.gaugeStyle(.accessoryCircular)
.tint(temperatureTint(for: info))
.frame(width: gaugeDiameter, height: gaugeDiameter)
HStack {
Text(minimumTemperatureLabel(for: info))
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
Spacer()
Text(maximumTemperatureLabel(for: info))
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
}
.frame(width: gaugeDiameter)
}
.layoutPriority(1)
}
private var locationSegment: some View {
Text(snapshot.locationName ?? "")
.font(isInline ? inlinePrimaryFont : inlineSecondaryFont)
.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
}
@ViewBuilder
private func chargingGlyph(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let iconName = chargingIconName(for: info) {
Image(systemName: iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
} else {
Image(systemName: "bolt.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
}
}
private func inlineChargingLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if let time = formattedChargingTime(for: info) {
return time
}
return chargingStatusFallback(for: info)
}
private func chargingDetailLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
inlineChargingLabel(for: info)
}
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 chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if info.isPluggedIn && !info.isCharging {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
}
return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
}
private func bluetoothPercentageText(for level: Int) -> String {
"\(clampedBatteryLevel(level))%"
}
private func minimumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
if let minimum = info.displayMinimum {
return "\(minimum)°"
}
return "—"
}
private func maximumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
if let maximum = info.displayMaximum {
return "\(maximum)°"
}
return "—"
}
private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
return HStack(alignment: .top, spacing: 2) {
Text("\(info.displayCurrent)°")
.font(.system(size: 20, weight: .semibold, design: .rounded))
}
.foregroundStyle(Color.white)
}
private func temperatureRange(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> ClosedRange<Double> {
let minimumCandidate = info.minimum ?? info.current
let maximumCandidate = info.maximum ?? info.current
var lowerBound = min(minimumCandidate, info.current)
var upperBound = max(maximumCandidate, info.current)
if lowerBound == upperBound {
lowerBound -= 1
upperBound += 1
}
return lowerBound...upperBound
}
private func clampedBatteryLevel(_ level: Int) -> Int {
min(max(level, 0), 100)
}
private func inlineComposite(primary: String, secondary: String?) -> Text {
var text = Text(primary).font(inlinePrimaryFont)
if let secondary, !secondary.isEmpty {
text = text + Text(" \(secondary)")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
return text
}
private func batteryTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let clamped = clampedBatteryLevel(level)
switch clamped {
case ..<20:
return Color(.systemRed)
case 20..<50:
return Color(.systemOrange)
default:
return Color(.systemGreen)
}
}
private func bluetoothTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let clamped = clampedBatteryLevel(level)
switch clamped {
case ..<20:
return Color(.systemRed)
case 20..<50:
return Color(.systemOrange)
default:
return Color(.systemGreen)
}
}
private func aqiTint(for category: LockScreenWeatherSnapshot.AirQualityInfo.Category) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
switch category {
case .good:
return Color(red: 0.20, green: 0.79, blue: 0.39)
case .moderate:
return Color(red: 0.97, green: 0.82, blue: 0.30)
case .unhealthyForSensitive:
return Color(red: 0.98, green: 0.57, blue: 0.24)
case .unhealthy:
return Color(red: 0.91, green: 0.29, blue: 0.25)
case .veryUnhealthy:
return Color(red: 0.65, green: 0.32, blue: 0.86)
case .hazardous:
return Color(red: 0.50, green: 0.13, blue: 0.28)
case .unknown:
return Color(red: 0.63, green: 0.66, blue: 0.74)
}
}
private func temperatureTint(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let value = info.current
switch value {
case ..<0:
return Color(red: 0.29, green: 0.63, blue: 1.00)
case 0..<15:
return Color(red: 0.20, green: 0.79, blue: 0.93)
case 15..<25:
return Color(red: 0.20, green: 0.79, blue: 0.39)
case 25..<32:
return Color(red: 0.97, green: 0.58, blue: 0.29)
default:
return Color(red: 0.91, green: 0.29, blue: 0.25)
}
}
private var accessibilityLabel: 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
)
)
} 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))
}
if let airQuality = snapshot.airQuality {
components.append(accessibilityAirQualityText(for: airQuality))
}
if let battery = snapshot.battery, !isInline {
components.append(accessibilityBatteryText(for: battery))
}
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")
}
if snapshot.showsChargingPercentage, let level = charging.batteryLevel {
return String(
format: NSLocalizedString("Battery at %d percent", comment: "Battery percentage"),
level
)
}
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
}
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
String(
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
bluetooth.deviceName,
bluetooth.batteryLevel
)
}
private func accessibilityAirQualityText(for airQuality: LockScreenWeatherSnapshot.AirQualityInfo) -> String {
String(
format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"),
airQuality.index,
airQuality.category.displayName
)
}
private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String {
String(
format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"),
clampedBatteryLevel(battery.batteryLevel)
)
}
private func batteryIconName(for level: Int) -> String {
let clamped = clampedBatteryLevel(level)
switch clamped {
case ..<10:
return "battery.0percent"
case 10..<40:
return "battery.25percent"
case 40..<70:
return "battery.50percent"
case 70..<90:
return "battery.75percent"
default:
return "battery.100percent"
}
}
}

View file

@ -23,15 +23,20 @@ struct MinimalisticMusicView: View {
Rectangle()
.fill(.black)
.overlay(
HStack(alignment: .center) {
if !musicManager.songTitle.isEmpty {
MarqueeText(
.constant(musicManager.songTitle),
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4,
frameWidth: 100
)
GeometryReader { geo in
HStack(alignment: .center) {
if !musicManager.songTitle.isEmpty {
MarqueeText(
$musicManager.songTitle,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4,
frameWidth: max(0, geo.size.width - 8)
)
}
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
}
)
.frame(width: vm.closedNotchSize.width)

View file

@ -15,8 +15,11 @@ struct DynamicIslandHeader: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var clipboardManager = ClipboardManager.shared
@ObservedObject var tvm = TrayDrop.shared
@ObservedObject var timerManager = TimerManager.shared
@State private var showClipboardPopover = false
@State private var showColorPickerPopover = false
@State private var showTimerPopover = false
@Default(.enableTimerFeature) var enableTimerFeature
var body: some View {
HStack(spacing: 0) {
@ -141,6 +144,36 @@ struct DynamicIslandHeader: View {
}
}
if Defaults[.enableTimerFeature] {
Button(action: {
withAnimation(.smooth) {
showTimerPopover.toggle()
}
}) {
Capsule()
.fill(.black)
.frame(width: 30, height: 30)
.overlay {
Image(systemName: "timer")
.foregroundColor(.white)
.padding()
.imageScale(.medium)
}
}
.buttonStyle(PlainButtonStyle())
.popover(isPresented: $showTimerPopover, arrowEdge: .bottom) {
TimerPopover()
}
.onChange(of: showTimerPopover) { isActive in
vm.isTimerPopoverActive = isActive
if !isActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
vm.shouldRecheckHover.toggle()
}
}
}
}
if Defaults[.settingsIconInNotch] {
Button(action: {
SettingsWindowController.shared.showWindow()
@ -204,6 +237,12 @@ struct DynamicIslandHeader: View {
showClipboardPopover.toggle()
}
}
.onChange(of: enableTimerFeature) { _, newValue in
if !newValue {
showTimerPopover = false
vm.isTimerPopoverActive = false
}
}
}
}

View file

@ -11,35 +11,45 @@ import Defaults
struct MinimalisticMusicPlayerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
let albumArtNamespace: Namespace.ID
@Default(.showMediaOutputControl) var showMediaOutputControl
var body: some View {
VStack(spacing: 0) {
// Header area with album art (matching DynamicIslandHeader height of 24pt)
HStack(alignment: .center, spacing: 10) {
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
.frame(width: 50, height: 50)
GeometryReader { headerGeo in
let albumArtWidth: CGFloat = 50
let spacing: CGFloat = 10
let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0
let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0))
HStack(alignment: .center, spacing: spacing) {
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
.frame(width: albumArtWidth, height: albumArtWidth)
// Song info aligned to bottom of album art
VStack(alignment: .leading, spacing: 1) {
Text(MusicManager.shared.songTitle)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
VStack(alignment: .leading, spacing: 1) {
if !musicManager.songTitle.isEmpty {
MarqueeText(
$musicManager.songTitle,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
frameWidth: textWidth
)
}
Text(MusicManager.shared.artistName)
.font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: MusicManager.shared.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1)
}
Text(musicManager.artistName)
.font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1)
}
.frame(width: textWidth, alignment: .leading)
Spacer()
// Visualizer aligned to bottom
if useMusicVisualizer {
visualizer
if useMusicVisualizer {
visualizer
.frame(width: visualizerWidth)
}
}
}
.frame(height: 50) // Fixed height to accommodate album art
.frame(height: 50)
// Compact progress bar
progressBar
@ -182,8 +192,12 @@ struct MinimalisticMusicPlayerView: View {
}
if Defaults[.showShuffleAndRepeat] {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
Task { await musicManager.toggleRepeat() }
if showMediaOutputControl {
MinimalisticMediaOutputButton()
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
Task { await musicManager.toggleRepeat() }
}
}
}
}
@ -216,6 +230,60 @@ struct MinimalisticMusicPlayerView: View {
action: action
)
}
private struct MinimalisticMediaOutputButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@EnvironmentObject private var vm: DynamicIslandViewModel
@State private var isPopoverPresented = false
@State private var isHoveringPopover = false
var body: some View {
MinimalisticSquircircleButton(
icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
fontSize: 18,
fontWeight: .medium,
frameSize: CGSize(width: 40, height: 40),
cornerRadius: 16,
foregroundColor: .white.opacity(0.85)
) {
isPopoverPresented.toggle()
if isPopoverPresented {
routeManager.refreshDevices()
}
}
.accessibilityLabel("Media output")
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
MediaOutputSelectorPopover(
routeManager: routeManager,
volumeModel: volumeModel,
onHoverChanged: { hovering in
isHoveringPopover = hovering
updateActivity()
}
) {
isPopoverPresented = false
isHoveringPopover = false
updateActivity()
}
}
.onChange(of: isPopoverPresented) { _, presented in
if !presented {
isHoveringPopover = false
}
updateActivity()
}
.onAppear {
routeManager.refreshDevices()
}
.onDisappear {
vm.isMediaOutputPopoverActive = false
}
}
private func updateActivity() {
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
private var repeatIcon: String {
switch musicManager.repeatMode {

View file

@ -18,10 +18,14 @@ struct MusicPlayerView: View {
let showShuffleAndRepeat: Bool
var body: some View {
HStack {
HStack(alignment: .top, spacing: 12) {
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat).drawingGroup().compositingGroup()
MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat)
.frame(maxWidth: .infinity, alignment: .leading)
.drawingGroup()
.compositingGroup()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ -116,6 +120,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) {
@ -123,6 +128,7 @@ struct MusicControlsView: View {
playbackControls
}
.buttonStyle(PlainButtonStyle())
.frame(maxWidth: .infinity, alignment: .leading)
}
private var songInfoAndSlider: some View {
@ -134,6 +140,7 @@ struct MusicControlsView: View {
}
.padding(.top, 10)
.padding(.leading, 5)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func songInfo(width: CGFloat) -> some View {
@ -192,8 +199,12 @@ struct MusicControlsView: View {
MusicManager.shared.nextTrack()
}
if showShuffleAndRepeat {
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
MusicManager.shared.toggleRepeat()
if showMediaOutputControl {
MediaOutputPickerButton()
} else {
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
MusicManager.shared.toggleRepeat()
}
}
}
}
@ -247,6 +258,7 @@ struct NotchHomeView: View {
} else {
// Normal mode: Show full music player with optional calendar and webcam
MusicPlayerView(albumArtNamespace: albumArtNamespace, showShuffleAndRepeat: Defaults[.showShuffleAndRepeat])
.frame(maxWidth: .infinity, alignment: .leading)
if Defaults[.showCalendar] {
CalendarView()
@ -382,6 +394,239 @@ struct CustomSlider: View {
}
}
private struct MediaOutputPickerButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@State private var isPopoverPresented = false
@State private var isHoveringPopover = false
@EnvironmentObject private var vm: DynamicIslandViewModel
var body: some View {
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
isPopoverPresented.toggle()
if isPopoverPresented {
routeManager.refreshDevices()
}
}
.accessibilityLabel("Media output")
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
MediaOutputSelectorPopover(
routeManager: routeManager,
volumeModel: volumeModel,
onHoverChanged: { hovering in
isHoveringPopover = hovering
updatePopoverActivity()
}
) {
isPopoverPresented = false
isHoveringPopover = false
updatePopoverActivity()
}
}
.onAppear {
routeManager.refreshDevices()
}
.onChange(of: isPopoverPresented) { _, presented in
if !presented {
isHoveringPopover = false
}
updatePopoverActivity()
}
.onDisappear {
vm.isMediaOutputPopoverActive = false
}
}
private var buttonIcon: String {
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
}
private func updatePopoverActivity() {
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
struct MediaOutputSelectorPopover: View {
@ObservedObject var routeManager: AudioRouteManager
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
var onHoverChanged: (Bool) -> Void
var dismiss: () -> Void
var body: some View {
VStack(spacing: 16) {
volumeSection
Divider()
devicesSection
}
.frame(width: 240)
.padding(16)
.onHover { hovering in
onHoverChanged(hovering)
}
.onDisappear {
onHoverChanged(false)
}
}
private var volumeSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Button {
volumeModel.toggleMute()
} label: {
Image(systemName: volumeIconName)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.primary)
.frame(width: 28, height: 28)
.background(
Circle()
.fill(Color.secondary.opacity(0.18))
)
.contentShape(Circle())
}
.buttonStyle(.plain)
Slider(
value: Binding(
get: { Double(volumeModel.level) },
set: { newValue in
volumeModel.setVolume(Float(newValue))
}
),
in: 0 ... 1
)
.tint(.accentColor)
}
HStack {
Text("Output volume")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(volumePercentage)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var devicesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Output devices")
.font(.caption)
.foregroundColor(.secondary)
if routeManager.devices.isEmpty {
Text("No audio outputs available")
.font(.callout)
.foregroundColor(.secondary)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ScrollView {
VStack(spacing: 6) {
ForEach(routeManager.devices) { device in
Button {
routeManager.select(device: device)
dismiss()
} label: {
HStack(spacing: 8) {
Image(systemName: device.iconName)
.font(.system(size: 14, weight: .medium))
Text(device.name)
.foregroundColor(.primary)
.lineLimit(1)
Spacer()
if device.id == routeManager.activeDeviceID {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
}
}
}
private var volumeIconName: String {
if volumeModel.isMuted || volumeModel.level <= 0.001 {
return "speaker.slash.fill"
} else if volumeModel.level < 0.33 {
return "speaker.wave.1.fill"
} else if volumeModel.level < 0.66 {
return "speaker.wave.2.fill"
}
return "speaker.wave.3.fill"
}
private var volumePercentage: String {
"\(Int(round(volumeModel.level * 100)))%"
}
}
final class MediaOutputVolumeViewModel: ObservableObject {
@Published var level: Float
@Published var isMuted: Bool
private let controller: SystemVolumeController
private var cancellables: Set<AnyCancellable> = []
init(controller: SystemVolumeController = .shared) {
self.controller = controller
controller.start()
level = controller.currentVolume
isMuted = controller.isMuted
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
.receive(on: RunLoop.main)
.sink { [weak self] notification in
guard let self,
let value = notification.userInfo?["value"] as? Float,
let muted = notification.userInfo?["muted"] as? Bool else { return }
self.level = value
self.isMuted = muted
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .systemAudioRouteDidChange)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.syncFromController()
}
.store(in: &cancellables)
}
func setVolume(_ value: Float) {
level = value
if value > 0 {
isMuted = false
}
controller.setVolume(value)
}
func toggleMute() {
isMuted.toggle()
controller.toggleMute()
}
private func syncFromController() {
level = controller.currentVolume
isMuted = controller.isMuted
}
}
#Preview {
NotchHomeView(
albumArtNamespace: Namespace().wrappedValue

View file

@ -70,7 +70,7 @@ struct NotchTimerView: View {
// Timer icon in center
Image(systemName: "timer")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(timerManager.currentColor)
.foregroundStyle(openIconColor)
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
.animation(
timerManager.isRunning && timerManager.isTimerActive ?
@ -287,6 +287,13 @@ struct NotchTimerView: View {
}
}
private extension NotchTimerView {
var openIconColor: Color {
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
return timerManager.currentColor
}
}
#Preview {
NotchTimerView()
.environmentObject(DynamicIslandViewModel())

View file

@ -4,13 +4,12 @@
//
// Created by Richard Kunkli on 07/08/2024.
//
import AVFoundation
import Defaults
import EventKit
import KeyboardShortcuts
import LaunchAtLogin
import LottieUI
import AVFoundation
import Sparkle
import SwiftUI
import SwiftUIIntrospect
@ -178,6 +177,17 @@ 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(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
var body: some View {
Form {
@ -324,6 +334,42 @@ 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 {
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) {
ForEach(LockScreenWeatherProviderSource.allCases) { source in
Text(source.displayName).tag(source)
}
}
.pickerStyle(.segmented)
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
Text(unit.rawValue).tag(unit)
}
}
.pickerStyle(.segmented)
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
.disabled(lockScreenWeatherWidgetStyle == .circular)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality)
if !lockScreenWeatherProviderSource.supportsAirQuality {
Text("Air quality requires the Open Meteo provider.")
.font(.caption)
.foregroundStyle(.secondary)
}
Defaults.Toggle("Use colored gauges", key: .lockScreenWeatherUsesGaugeTint)
}
Button("Copy Latest Crash Report") {
copyLatestCrashReport()
@ -625,9 +671,6 @@ struct HUD: View {
Section {
Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD)
.onChange(of: enableSystemHUD) { _ in
// SystemHUDManager will automatically handle start/stop via its @Default observer
}
if enableSystemHUD {
Toggle("Volume HUD", isOn: $enableVolumeHUD)
@ -666,6 +709,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 +721,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 +793,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 +834,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")
}
@ -1689,9 +1743,19 @@ func warningBadge(_ text: String, _ description: String) -> some View {
struct TimerSettings: View {
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
@Default(.timerPresets) private var timerPresets
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerShowsCountdown) private var showsCountdown
@Default(.timerShowsLabel) private var showsLabel
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerProgressStyle) private var progressStyle
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@State private var customHours: Int = 0
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
@State private var showingResetConfirmation = false
var body: some View {
Form {
@ -1699,137 +1763,221 @@ struct TimerSettings: View {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
Toggle(
"Enable timer live activity",
isOn: $coordinator.timerLiveActivityEnabled
)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
}
} header: {
Text("Timer Feature")
} footer: {
Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
Text("Control timer availability and whether progress appears as a live activity while the notch is closed.")
}
if enableTimerFeature {
Section {
VStack(alignment: .leading, spacing: 16) {
Text("Custom Timer Duration")
.font(.headline)
Group {
Section {
VStack(alignment: .leading, spacing: 12) {
Text("Default Custom Timer")
.font(.headline)
HStack(spacing: 20) {
VStack(alignment: .center, spacing: 8) {
Text("Minutes")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
Picker(selection: $customMinutes, label: Text("Minutes")) {
ForEach(0...59, id: \.self) { minute in
Text("\(minute)").tag(minute)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
VStack(alignment: .center, spacing: 8) {
Text("Seconds")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Picker(selection: $customSeconds, label: Text("Seconds")) {
ForEach(0...59, id: \.self) { second in
Text("\(second)").tag(second)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
}
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Current custom timer:")
Text("Current default:")
.foregroundStyle(.secondary)
Text(customTimerDisplayText)
.foregroundStyle(.primary)
Text(customDurationDisplay)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
}
.padding(.vertical, 4)
.onChange(of: customHours) { _, _ in updateCustomDuration() }
.onChange(of: customMinutes) { _, _ in updateCustomDuration() }
.onChange(of: customSeconds) { _, _ in updateCustomDuration() }
} header: {
Text("Custom Timer")
} footer: {
Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
}
HStack {
Spacer()
Button("Update Timer") {
customTimerDuration = Double(customMinutes * 60 + customSeconds)
}
.disabled(customMinutes == 0 && customSeconds == 0)
.buttonStyle(.borderedProminent)
Section {
Picker("Timer tint", selection: $colorMode) {
ForEach(TimerIconColorMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
if colorMode == .solid {
ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false)
}
Toggle("Show timer name", isOn: $showsLabel)
Toggle("Show countdown", isOn: $showsCountdown)
Toggle("Show progress", isOn: $showsProgress)
Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled)
.disabled(showsLabel)
.help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.")
Picker("Progress style", selection: $progressStyle) {
ForEach(TimerProgressStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
.disabled(!showsProgress)
} header: {
Text("Appearance")
} footer: {
Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
}
Section {
if timerPresets.isEmpty {
Text("No presets configured. Add a preset to make it appear in the timer popover.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
ForEach(timerPresets.indices, id: \.self) { index in
TimerPresetEditorRow(
preset: $timerPresets[index],
isFirst: index == timerPresets.startIndex,
isLast: index == timerPresets.index(before: timerPresets.endIndex),
moveUp: { movePresetUp(index) },
moveDown: { movePresetDown(index) },
remove: { removePreset(index) }
)
}
}
}
.padding(.vertical, 8)
} header: {
Text("Custom Timer")
} footer: {
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
}
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
Button("Choose File") {
selectCustomTimerSound()
Button(action: addPreset) {
Label("Add Preset", systemImage: "plus")
}
.buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
Button(role: .destructive, action: { showingResetConfirmation = true }) {
Label("Restore Defaults", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
Button("Restore", role: .destructive, action: resetPresets)
}
}
.buttonStyle(.bordered)
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
} header: {
Text("Timer Presets")
} footer: {
Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
}
Section {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
Button("Choose File", action: selectCustomTimerSound)
.buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
}
.buttonStyle(.bordered)
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
}
} header: {
Text("Timer Sound")
} footer: {
Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
}
}
.onAppear {
if showsLabel {
controlWindowEnabled = false
}
}
.onChange(of: showsLabel) { _, show in
if show {
controlWindowEnabled = false
}
} header: {
Text("Timer Sound")
} footer: {
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
}
}
}
.navigationTitle("Timer")
.onAppear {
let totalMinutes = Int(customTimerDuration) / 60
customMinutes = totalMinutes
customSeconds = Int(customTimerDuration) % 60
.onAppear { syncCustomDuration() }
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) }
}
private var customDurationDisplay: String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
return formatter.string(from: customTimerDuration) ?? "0:00"
}
private func syncCustomDuration(_ value: Double? = nil) {
let baseValue = value ?? customTimerDuration
let components = TimerPreset.components(for: baseValue)
customHours = components.hours
customMinutes = components.minutes
customSeconds = components.seconds
}
private func updateCustomDuration() {
let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
customTimerDuration = duration
}
private func addPreset() {
let nextIndex = timerPresets.count + 1
let defaultColor = Defaults[.accentColor]
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
_ = withAnimation(.smooth) {
timerPresets.append(newPreset)
}
}
private var customTimerDisplayText: String {
let totalMinutes = Int(customTimerDuration) / 60
let seconds = Int(customTimerDuration) % 60
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
private func movePresetUp(_ index: Int) {
guard index > timerPresets.startIndex else { return }
_ = withAnimation(.smooth) {
timerPresets.swapAt(index, index - 1)
}
}
if hours > 0 {
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
} else if minutes > 0 {
return "\(minutes):\(String(format: "%02d", seconds))"
} else {
return "\(seconds)s"
private func movePresetDown(_ index: Int) {
guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
_ = withAnimation(.smooth) {
timerPresets.swapAt(index, index + 1)
}
}
private func removePreset(_ index: Int) {
guard timerPresets.indices.contains(index) else { return }
_ = withAnimation(.smooth) {
timerPresets.remove(at: index)
}
}
private func resetPresets() {
_ = withAnimation(.smooth) {
timerPresets = TimerPreset.defaultPresets
}
}
@ -1849,6 +1997,146 @@ struct TimerSettings: View {
}
}
private struct TimerDurationStepperRow: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
HStack {
Text(title)
Spacer()
Text("\(value)")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
}
}
}
private struct TimerPresetEditorRow: View {
@Binding var preset: TimerPreset
let isFirst: Bool
let isLast: Bool
let moveUp: () -> Void
let moveDown: () -> Void
let remove: () -> Void
private var components: TimerPreset.DurationComponents {
TimerPreset.components(for: preset.duration)
}
private var hoursBinding: Binding<Int> {
Binding(
get: { components.hours },
set: { updateDuration(hours: $0) }
)
}
private var minutesBinding: Binding<Int> {
Binding(
get: { components.minutes },
set: { updateDuration(minutes: $0) }
)
}
private var secondsBinding: Binding<Int> {
Binding(
get: { components.seconds },
set: { updateDuration(seconds: $0) }
)
}
private var colorBinding: Binding<Color> {
Binding(
get: { preset.color },
set: { preset.updateColor($0) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(preset.color.gradient)
.frame(width: 20, height: 20)
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Spacer()
Text(preset.formattedDuration)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
HStack(spacing: 16) {
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
}
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
.frame(maxWidth: 240, alignment: .leading)
HStack(spacing: 12) {
Button(action: moveUp) {
Label("Move Up", systemImage: "chevron.up")
}
.buttonStyle(.bordered)
.disabled(isFirst)
Button(action: moveDown) {
Label("Move Down", systemImage: "chevron.down")
}
.buttonStyle(.bordered)
.disabled(isLast)
Spacer()
Button(role: .destructive, action: remove) {
Label("Delete", systemImage: "trash")
}
.buttonStyle(.bordered)
}
.font(.system(size: 12, weight: .medium))
}
.padding(.vertical, 6)
}
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
var values = components
if let hours { values.hours = hours }
if let minutes { values.minutes = minutes }
if let seconds { values.seconds = seconds }
preset.duration = TimerPreset.duration(from: values)
}
}
private struct TimerPresetComponentControl: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(value)")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
}
}
.frame(width: 110, alignment: .leading)
}
}
struct StatsSettings: View {
@ObservedObject var statsManager = StatsManager.shared
@Default(.enableStatsFeature) var enableStatsFeature

View file

@ -17,7 +17,6 @@ struct TabModel: Identifiable {
struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Namespace var animation
@ -29,11 +28,6 @@ struct TabSelectionView: View {
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
// Timer tab only shown when timer feature is enabled
if Defaults[.enableTimerFeature] {
tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
}
// Stats tab only shown when stats feature is enabled
if Defaults[.enableStatsFeature] {
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))

View file

@ -0,0 +1,88 @@
import SwiftUI
struct TimerControlOverlay: View {
let notchHeight: CGFloat
let cornerRadius: CGFloat
@ObservedObject private var timerManager = TimerManager.shared
private var pauseIcon: String {
timerManager.isPaused ? "play.fill" : "pause.fill"
}
private var pauseForeground: Color {
.white
}
private var helpText: String {
timerManager.isPaused ? "Resume" : "Pause"
}
private var buttonSize: CGFloat {
max(notchHeight - 20, 22)
}
private var iconSize: CGFloat {
14
}
private var windowCornerRadius: CGFloat {
max(cornerRadius - 6, 12)
}
var body: some View {
HStack(spacing: 12) {
Button(action: togglePause) {
Image(systemName: pauseIcon)
.font(.system(size: iconSize, weight: .semibold))
.frame(width: buttonSize, height: buttonSize)
.foregroundStyle(pauseForeground)
}
.buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.help(helpText)
Button(action: stopTimer) {
Image(systemName: "stop.fill")
.font(.system(size: iconSize, weight: .semibold))
.frame(width: buttonSize, height: buttonSize)
.foregroundStyle(Color.red)
}
.buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.help("Stop")
}
.padding(.horizontal, 12)
.frame(height: notchHeight)
.frame(minWidth: buttonSize * 2 + 32)
.background {
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
.fill(Color.black.opacity(0.9))
}
.compositingGroup()
.animation(.smooth(duration: 0.2), value: timerManager.isPaused)
.animation(.smooth(duration: 0.2), value: timerManager.isFinished)
.animation(.smooth(duration: 0.2), value: timerManager.isOvertime)
}
private func togglePause() {
if timerManager.isPaused {
timerManager.resumeTimer()
} else {
timerManager.pauseTimer()
}
}
private func stopTimer() {
#if os(macOS)
TimerControlWindowManager.shared.hide(animated: true)
#endif
timerManager.stopTimer()
}
}
#Preview {
TimerControlOverlay(notchHeight: 34, cornerRadius: 14)
.padding()
.background(Color.gray.opacity(0.2))
}

View file

@ -6,95 +6,43 @@
//
import SwiftUI
import Combine
import Defaults
struct TimerIconAnimation: View {
@ObservedObject var timerManager = TimerManager.shared
@State private var pulseScale: CGFloat = 1.0
@State private var rotationAngle: Double = 0
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerPresets) private var timerPresets
@Default(.accentColor) private var accentColor
var body: some View {
ZStack {
// Background circle with timer color
Circle()
.fill(timerManager.timerColor.gradient)
.fill(resolvedColor.gradient)
.frame(width: 24, height: 24)
.scaleEffect(pulseScale)
.animation(
timerManager.isTimerActive && !timerManager.isPaused
? .easeInOut(duration: 1.0).repeatForever(autoreverses: true)
: .easeInOut(duration: 0.3),
value: pulseScale
)
// Timer icon
Image(systemName: "timer")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.rotationEffect(.degrees(rotationAngle))
.animation(
timerManager.isTimerActive && !timerManager.isPaused
? .linear(duration: 2.0).repeatForever(autoreverses: false)
: .easeInOut(duration: 0.3),
value: rotationAngle
)
// Progress ring
Circle()
.trim(from: 0, to: timerManager.progress)
.stroke(
Color.white.opacity(0.8),
style: StrokeStyle(lineWidth: 2, lineCap: .round)
)
.frame(width: 26, height: 26)
.rotationEffect(.degrees(-90)) // Start from top
.animation(.easeInOut(duration: 0.5), value: timerManager.progress)
}
.onAppear {
startAnimations()
}
.onChange(of: timerManager.isTimerActive) { _, isActive in
if isActive {
startAnimations()
} else {
stopAnimations()
}
}
.onChange(of: timerManager.isPaused) { _, isPaused in
if isPaused {
stopAnimations()
} else if timerManager.isTimerActive {
startAnimations()
}
.foregroundStyle(.white)
}
}
private func startAnimations() {
guard timerManager.isTimerActive && !timerManager.isPaused else { return }
// Start pulsing
pulseScale = 1.1
// Start rotation
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
rotationAngle = 360
}
}
private func stopAnimations() {
// Stop pulsing
pulseScale = 1.0
// Stop rotation
withAnimation(.easeInOut(duration: 0.3)) {
rotationAngle = 0
private var resolvedColor: Color {
guard timerManager.isTimerActive else { return accentColor }
switch colorMode {
case .adaptive:
if let presetId = timerManager.activePresetId,
let preset = timerPresets.first(where: { $0.id == presetId }) {
return preset.color
}
return timerManager.timerColor
case .solid:
return solidColor
}
}
}
struct TimerIconView: NSViewRepresentable {
@ObservedObject var timerManager = TimerManager.shared
func makeNSView(context: Context) -> NSView {
let hostingView = NSHostingView(rootView: TimerIconAnimation())
hostingView.translatesAutoresizingMaskIntoConstraints = false

View file

@ -8,66 +8,470 @@
import SwiftUI
import Defaults
#if canImport(AppKit)
import AppKit
typealias PlatformFont = NSFont
#elseif canImport(UIKit)
import UIKit
typealias PlatformFont = UIFont
#endif
struct TimerLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var timerManager = TimerManager.shared
@State private var isHovering: Bool = false
@State private var gestureProgress: CGFloat = 0
@State private var showTransientLabel: Bool = false
@State private var labelHideTask: DispatchWorkItem?
@State private var isControlWindowVisible: Bool = false
@Default(.timerShowsCountdown) private var showsCountdown
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerShowsLabel) private var showsLabel
@Default(.timerProgressStyle) private var progressStyle
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerPresets) private var timerPresets
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
private var notchContentHeight: CGFloat {
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
}
private var wingPadding: CGFloat { 22 }
private var ringStrokeWidth: CGFloat { 3 }
private var transientLabelDuration: TimeInterval { 4 }
private var ringWrapsIcon: Bool {
showsRingProgress && showsCountdown
}
private var ringOnRight: Bool {
showsRingProgress && !ringWrapsIcon
}
private var iconWidth: CGFloat {
ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
}
private var infoContentWidth: CGFloat {
guard showsInfoSection else { return 0 }
if shouldDisplayLabel {
let textWidth = min(max(titleTextWidth, 44), 220)
return textWidth
} else {
return min(max(notchContentHeight * 1.4, 64), 220)
}
}
private var infoWidth: CGFloat {
guard showsInfoSection else { return 0 }
return infoContentWidth + 18
}
private var leftWingWidth: CGFloat {
var width = iconWidth + wingPadding
if showsInfoSection {
width += 8 + infoWidth
}
return width
}
private var ringWidth: CGFloat {
ringOnRight ? 30 : 0
}
private var rightWingWidth: CGFloat {
var width = wingPadding
if ringOnRight {
width += ringWidth
}
if ringOnRight && showsCountdown {
width += 8
}
if showsCountdown {
width += countdownWidth
}
return width
}
private var titleTextWidth: CGFloat {
measureTextWidth(timerManager.timerName, font: systemFont(size: 12, weight: .medium))
}
private var countdownTextWidth: CGFloat {
measureTextWidth(timerManager.formattedRemainingTime(), font: monospacedDigitFont(size: 13, weight: .semibold))
}
private var countdownWidth: CGFloat {
guard showsCountdown else { return 0 }
return max(countdownTextWidth + 16, 72)
}
private var clampedProgress: Double {
min(max(timerManager.progress, 0), 1)
}
private var glyphColor: Color {
switch colorMode {
case .adaptive:
return activePresetColor ?? timerManager.timerColor
case .solid:
return solidColor
}
}
private var showsRingProgress: Bool {
showsProgress && progressStyle == .ring
}
private var showsBarProgress: Bool {
showsProgress && progressStyle == .bar
}
private var shouldDisplayLabel: Bool {
showsLabel || showTransientLabel
}
private var showsInfoSection: Bool {
shouldDisplayLabel || (showsBarProgress && !showsCountdown)
}
private var activePresetColor: Color? {
guard let presetId = timerManager.activePresetId else { return nil }
return timerPresets.first { $0.id == presetId }?.color
}
private var shouldShowControlWindow: Bool {
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive
}
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: font]
let width = NSAttributedString(string: text, attributes: attributes).size().width
return CGFloat(ceil(width))
}
private func systemFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
#if canImport(AppKit)
return NSFont.systemFont(ofSize: size, weight: weight)
#else
return UIFont.systemFont(ofSize: size, weight: weight)
#endif
}
private func monospacedDigitFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
#if canImport(AppKit)
return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
#else
return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
#endif
}
var body: some View {
HStack {
// Left side - Timer icon animation
timerIconSection
HStack(spacing: 0) {
Color.clear
.frame(width: leftWingWidth, height: notchContentHeight)
.background(alignment: .leading) {
HStack(spacing: showsInfoSection ? 8 : 0) {
iconSection
if showsInfoSection {
infoSection
}
}
.padding(.leading, wingPadding / 2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
// Center - Expandable timer info (similar to music banner)
timerInfoSection
Rectangle()
.fill(.black)
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
// Right side - Timer countdown text
timerCountdownSection
Color.clear
.frame(width: rightWingWidth, height: notchContentHeight)
.background(alignment: .trailing) {
HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) {
if ringOnRight {
ringSection
}
if showsCountdown {
countdownSection
}
}
.padding(.trailing, wingPadding / 2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
}
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
}
private var timerIconSection: some View {
HStack {
// Simple timer icon with color, no background circle or animations
Image(systemName: "timer")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(timerManager.timerColor)
.frame(width: 24, height: 24)
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.smooth(duration: 0.18)) {
isHovering = hovering
}
syncControlWindow(forceRefresh: true)
}
.onAppear {
syncControlWindow(forceRefresh: true)
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
triggerTransientLabel()
}
}
.onDisappear {
hideControlWindow()
}
.onChange(of: timerManager.isTimerActive) { _, isActive in
if isActive {
if !timerManager.isFinished && !timerManager.isOvertime {
syncControlWindow(forceRefresh: true)
triggerTransientLabel()
}
} else {
hideControlWindow()
cancelTransientLabel()
showTransientLabel = false
isHovering = false
}
}
.onChange(of: timerManager.timerName) { _, _ in
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
syncControlWindow(forceRefresh: true)
triggerTransientLabel()
}
}
.onChange(of: timerManager.isFinished) { _, finished in
if finished {
cancelTransientLabel()
withAnimation(.smooth) {
showTransientLabel = true
isHovering = false
}
syncControlWindow()
}
}
.onChange(of: timerManager.isOvertime) { _, overtime in
if overtime {
cancelTransientLabel()
withAnimation(.smooth) {
showTransientLabel = true
isHovering = false
}
syncControlWindow()
}
}
.onChange(of: timerManager.isPaused) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: vm.closedNotchSize) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: vm.screen) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: vm.hideOnClosed) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: controlWindowEnabled) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: showsLabel) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: showTransientLabel) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: showsCountdown) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: showsProgress) { _, _ in
syncControlWindow(forceRefresh: true)
}
.onChange(of: progressStyle) { _, _ in
syncControlWindow(forceRefresh: true)
}
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
}
private var timerInfoSection: some View {
Rectangle()
private var iconSection: some View {
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth
let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6)
return ZStack {
if ringWrapsIcon {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
.frame(width: ringDiameter, height: ringDiameter)
Circle()
.trim(from: 0, to: clampedProgress)
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: clampedProgress)
.frame(width: ringDiameter, height: ringDiameter)
}
Image(systemName: "timer")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(glyphColor)
.frame(width: iconSize, height: iconSize)
}
.frame(width: ringWrapsIcon ? ringDiameter : iconWidth,
height: notchContentHeight,
alignment: .center)
}
private var infoSection: some View {
let availableWidth = max(0, infoWidth - 10)
let safeWidth = max(44, availableWidth - 6)
let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth)
let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
let showsBarHere = showsBarProgress && !showsCountdown
let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
return Rectangle()
.fill(.black)
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
.frame(width: infoWidth, height: notchContentHeight)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
if shouldDisplayLabel {
if marqueeLabel {
MarqueeText(
.constant(timerManager.timerName),
font: .system(size: 12, weight: .medium),
nsFont: .callout,
textColor: .white,
minDuration: 0.25,
frameWidth: resolvedTextWidth
)
} else {
Text(timerManager.timerName)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)
.transition(.opacity.combined(with: .move(edge: .top)))
.frame(width: resolvedTextWidth, alignment: .leading)
}
}
if showsBarHere {
Capsule()
.fill(Color.white.opacity(0.12))
.frame(width: barWidth, height: 3)
.overlay(alignment: .leading) {
Capsule()
.fill(glyphColor)
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
}
}
.padding(.leading, 12)
.padding(.trailing, 6)
}
.animation(.smooth, value: timerManager.isFinished)
}
private var timerCountdownSection: some View {
HStack {
VStack(spacing: 2) {
// Remaining time
Text(timerManager.formattedRemainingTime())
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(timerManager.isOvertime ? .red : .white)
private var ringSection: some View {
let diameter = max(min(notchContentHeight - 4, 26), 20)
return ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
Circle()
.trim(from: 0, to: clampedProgress)
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
.frame(width: diameter, height: diameter)
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
}
// Progress indicator
if timerManager.totalDuration > 0 {
ProgressView(value: timerManager.progress)
.progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
.frame(width: 30, height: 2)
private var countdownSection: some View {
let barWidth = max(countdownTextWidth, 1)
return VStack(spacing: 4) {
Text(timerManager.formattedRemainingTime())
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(timerManager.isOvertime ? .red : .white)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
.frame(maxWidth: .infinity, alignment: .trailing)
if showsBarProgress {
Capsule()
.fill(Color.white.opacity(0.12))
.frame(width: barWidth, height: 3)
.overlay(alignment: .leading) {
Capsule()
.fill(glyphColor)
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(.trailing, 8)
.frame(width: countdownWidth,
height: notchContentHeight, alignment: .center)
}
private func triggerTransientLabel() {
guard !showsLabel else { return }
cancelTransientLabel()
withAnimation(.smooth) {
showTransientLabel = true
}
let task = DispatchWorkItem {
withAnimation(.smooth) {
showTransientLabel = false
}
syncControlWindow(forceRefresh: true)
}
labelHideTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
}
private func cancelTransientLabel() {
labelHideTask?.cancel()
labelHideTask = nil
}
private func syncControlWindow(forceRefresh: Bool = false) {
#if os(macOS)
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
let targetVisible = shouldShowControlWindow && notchAvailable
if targetVisible {
let metrics = currentControlWindowMetrics()
if !isControlWindowVisible {
let didPresent = TimerControlWindowManager.shared.present(using: vm, metrics: metrics)
isControlWindowVisible = didPresent
} else if forceRefresh {
let didRefresh = TimerControlWindowManager.shared.refresh(using: vm, metrics: metrics)
isControlWindowVisible = didRefresh
if !didRefresh {
TimerControlWindowManager.shared.hide()
}
}
.frame(maxWidth: .infinity)
} else if isControlWindowVisible {
TimerControlWindowManager.shared.hide()
isControlWindowVisible = false
}
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
#endif
}
private func hideControlWindow() {
#if os(macOS)
if isControlWindowVisible {
TimerControlWindowManager.shared.hide()
isControlWindowVisible = false
}
#endif
}
private func currentControlWindowMetrics() -> TimerControlWindowMetrics {
TimerControlWindowMetrics(
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
rightWingWidth: rightWingWidth,
cornerRadius: cornerRadiusInsets.closed.bottom,
spacing: 6
)
}
}
@ -77,7 +481,6 @@ struct TimerLiveActivity: View {
.frame(width: 300, height: 32)
.background(.black)
.onAppear {
// Start a demo timer for preview
TimerManager.shared.startDemoTimer(duration: 300)
}
}

View file

@ -0,0 +1,362 @@
//
// TimerPopover.swift
// DynamicIsland
//
// Created by GitHub Copilot on 2025-11-01.
//
import SwiftUI
import Defaults
struct TimerPopover: View {
@ObservedObject var timerManager = TimerManager.shared
@Default(.timerPresets) private var timerPresets
@Default(.timerShowsCountdown) private var showsCountdown
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerProgressStyle) private var progressStyle
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@Environment(\.dismiss) private var dismiss
@State private var customHours: Int = 0
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
private var customDurationInSeconds: TimeInterval {
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
}
var body: some View {
VStack(spacing: 16) {
HeaderView(statusText: statusText)
if timerManager.isTimerActive {
ActiveTimerSection(timerManager: timerManager)
} else {
CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer)
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
}
Divider()
.padding(.horizontal, -8)
PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset)
.animation(.smooth, value: timerManager.activePresetId)
.frame(maxHeight: 200)
}
.padding(16)
.frame(width: 300)
.background(
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.cornerRadius(12)
)
.onAppear {
syncCustomDuration(with: customTimerDuration)
}
.onChange(of: customTimerDuration) { _, newValue in
syncCustomDuration(with: newValue)
}
}
private var statusText: String {
if timerManager.isOvertime {
return "Overtime"
} else if timerManager.isPaused {
return "Paused"
} else if timerManager.isTimerActive {
return "Running"
} else {
return "Ready"
}
}
private func syncCustomDuration(with value: Double) {
let components = TimerPreset.components(for: value)
customHours = components.hours
customMinutes = components.minutes
customSeconds = components.seconds
}
private func updateStoredCustomDuration() {
let newValue = customDurationInSeconds
customTimerDuration = newValue
}
private func startCustomTimer() {
let duration = customDurationInSeconds
guard duration > 0 else { return }
withAnimation(.smooth) {
timerManager.startTimer(duration: duration, name: "Custom Timer")
}
dismiss()
}
private func startPreset(_ preset: TimerPreset) {
withAnimation(.smooth) {
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
}
dismiss()
}
}
private struct HeaderView: View {
let statusText: String
var body: some View {
HStack(spacing: 12) {
TimerIconAnimation()
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Timer")
.font(.system(size: 14, weight: .semibold))
Text(statusText)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
private struct ActiveTimerSection: View {
@ObservedObject var timerManager: TimerManager
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerProgressStyle) private var progressStyle
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerPresets) private var timerPresets
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(timerManager.timerName)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.foregroundColor(.primary)
Text(timerManager.formattedRemainingTime())
.font(.system(size: 28, weight: .bold, design: .monospaced))
.foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary)
.contentTransition(.numericText())
}
if showsProgress && progressStyle == .bar {
ProgressView(value: min(timerManager.progress, 1.0))
.progressViewStyle(LinearProgressViewStyle(tint: progressTint))
.animation(.smooth(duration: 0.2), value: timerManager.progress)
}
HStack(spacing: 8) {
Button(action: togglePause) {
Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .medium))
.labelStyle(.titleAndIcon)
}
.buttonStyle(.bordered)
Button(role: .destructive, action: stopTimer) {
Label("Stop", systemImage: "stop.fill")
.font(.system(size: 12, weight: .medium))
.labelStyle(.titleAndIcon)
}
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.animation(.smooth, value: timerManager.isPaused)
}
private func togglePause() {
withAnimation(.smooth) {
if timerManager.isPaused {
timerManager.resumeTimer()
} else {
timerManager.pauseTimer()
}
}
}
private func stopTimer() {
withAnimation(.smooth) {
timerManager.stopTimer()
}
}
private var progressTint: Color {
switch colorMode {
case .adaptive:
return activePresetColor ?? timerManager.timerColor
case .solid:
return solidColor
}
}
private var activePresetColor: Color? {
guard let presetId = timerManager.activePresetId else { return nil }
return timerPresets.first { $0.id == presetId }?.color
}
}
private struct CustomTimerSection: View {
@Binding var hours: Int
@Binding var minutes: Int
@Binding var seconds: Int
let startAction: () -> Void
private var totalSeconds: Int {
hours * 3600 + minutes * 60 + seconds
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Custom Timer")
.font(.system(size: 14, weight: .semibold))
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
DurationStepper(title: "Hours", value: $hours, range: 0...23)
DurationStepper(title: "Minutes", value: $minutes, range: 0...59)
DurationStepper(title: "Seconds", value: $seconds, range: 0...59)
}
}
Text(formattedDuration)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
Button(action: startAction) {
Label("Start Custom Timer", systemImage: "play.fill")
.font(.system(size: 13, weight: .medium))
.labelStyle(.titleAndIcon)
}
.buttonStyle(.borderedProminent)
.disabled(totalSeconds == 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private var formattedDuration: String {
let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
let interval = TimerPreset.duration(from: components)
return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration
}
}
private struct DurationStepper: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
HStack {
Text(title)
.font(.system(size: 12, weight: .medium))
Spacer()
Text("\(value)")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
}
.frame(width: 88)
}
}
private struct PresetList: View {
let presets: [TimerPreset]
let activePresetId: UUID?
let startAction: (TimerPreset) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Presets")
.font(.system(size: 13, weight: .semibold))
.padding(.leading, 4)
if presets.isEmpty {
Text("Configure presets in Settings")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(presets) { preset in
TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) {
startAction(preset)
}
}
}
.padding(.vertical, 4)
}
}
}
}
}
private struct TimerPresetRow: View {
let preset: TimerPreset
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Circle()
.fill(preset.color.gradient)
.frame(width: 20, height: 20)
.overlay(
Circle()
.stroke(Color.white.opacity(0.5), lineWidth: 1)
)
VStack(alignment: .leading, spacing: 2) {
Text(preset.name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(preset.formattedDuration)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isActive ? "checkmark" : "play.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(isActive ? preset.color : Color.secondary)
.padding(6)
.background(isActive ? preset.color.opacity(0.2) : Color.clear)
.clipShape(Circle())
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05))
)
}
.buttonStyle(.plain)
}
}
#Preview {
TimerPopover()
.frame(width: 300)
.padding()
.background(Color.black)
}

View file

@ -77,3 +77,51 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi
var id: String { rawValue }
}
enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
case inline = "Inline"
case circular = "Circular"
var id: String { rawValue }
}
enum LockScreenWeatherProviderSource: String, CaseIterable, Defaults.Serializable, Identifiable {
case wttr = "wttr.in"
case openMeteo = "Open Meteo"
var id: String { rawValue }
var displayName: String { rawValue }
var supportsAirQuality: Bool {
switch self {
case .wttr:
return false
case .openMeteo:
return true
}
}
}
enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializable, Identifiable {
case celsius = "Celsius"
case fahrenheit = "Fahrenheit"
var id: String { rawValue }
var usesMetricSystem: Bool { self == .celsius }
var symbol: String {
switch self {
case .celsius: return "°C"
case .fahrenheit: return "°F"
}
}
var openMeteoTemperatureParameter: String? {
switch self {
case .celsius: return nil
case .fahrenheit: return "fahrenheit"
}
}
}

View file

@ -0,0 +1,245 @@
import Combine
import CoreAudio
import Foundation
struct AudioOutputDevice: Identifiable, Equatable {
let id: AudioDeviceID
let name: String
let transportType: UInt32
var iconName: String {
let normalizedName = name.lowercased()
if normalizedName.contains("airpods") {
return "airpodspro"
}
if normalizedName.contains("macbook") {
return "laptopcomputer"
}
if normalizedName.contains("headphone") || normalizedName.contains("headset") {
return "headphones"
}
if normalizedName.contains("beats") {
return "headphones"
}
if normalizedName.contains("homepod") {
return "hifispeaker.2"
}
switch transportType {
case kAudioDeviceTransportTypeBluetooth:
if normalizedName.contains("speaker") {
return "speaker.wave.2"
}
return "headphones"
case kAudioDeviceTransportTypeAirPlay:
return "airplayaudio"
case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI:
return "tv"
case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeFireWire:
return "hifispeaker.2"
case kAudioDeviceTransportTypePCI, kAudioDeviceTransportTypeVirtual:
return "speaker.wave.2"
case kAudioDeviceTransportTypeBuiltIn:
return normalizedName.contains("display") ? "tv" : "speaker.wave.2"
default:
return "speaker.wave.2"
}
}
}
final class AudioRouteManager: ObservableObject {
static let shared = AudioRouteManager()
@Published private(set) var devices: [AudioOutputDevice] = []
@Published private(set) var activeDeviceID: AudioDeviceID = 0
private let queue = DispatchQueue(label: "com.dynamicisland.audio-route", qos: .userInitiated)
private init() {
refreshDevices()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: .systemAudioRouteDidChange,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
var activeDevice: AudioOutputDevice? {
devices.first { $0.id == activeDeviceID }
}
func refreshDevices() {
queue.async { [weak self] in
guard let self else { return }
let defaultID = self.fetchDefaultOutputDevice()
let deviceInfos = self.fetchOutputDeviceIDs().compactMap(self.makeDeviceInfo)
let sortedDevices = deviceInfos.sorted { lhs, rhs in
if lhs.id == defaultID { return true }
if rhs.id == defaultID { return false }
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
DispatchQueue.main.async {
self.activeDeviceID = defaultID
self.devices = sortedDevices
}
}
}
func select(device: AudioOutputDevice) {
queue.async { [weak self] in
guard let self else { return }
self.setDefaultOutputDevice(device.id)
}
}
// MARK: - Private
@objc private func handleRouteChange() {
refreshDevices()
}
private func fetchOutputDeviceIDs() -> [AudioDeviceID] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&dataSize
) == noErr else {
return []
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&dataSize,
&deviceIDs
)
if status != noErr {
return []
}
return deviceIDs
}
private func makeDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? {
guard deviceHasOutputChannels(deviceID) else { return nil }
let name = deviceName(for: deviceID) ?? "Unknown Device"
let transport = transportType(for: deviceID)
return AudioOutputDevice(id: deviceID, name: name, transportType: transport)
}
private func deviceHasOutputChannels(_ deviceID: AudioDeviceID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr else {
return false
}
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { buffer.deallocate() }
guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, buffer) == noErr else {
return false
}
let audioBufferListPointer = buffer.assumingMemoryBound(to: AudioBufferList.self)
let audioBuffers = UnsafeMutableAudioBufferListPointer(audioBufferListPointer)
let channelCount = audioBuffers.reduce(0) { $0 + Int($1.mNumberChannels) }
return channelCount > 0
}
private func deviceName(for deviceID: AudioDeviceID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioObjectPropertyName,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var name: CFString = "" as CFString
var dataSize = UInt32(MemoryLayout<CFString?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &name)
guard status == noErr else { return nil }
return name as String
}
private func transportType(for deviceID: AudioDeviceID) -> UInt32 {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyTransportType,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var type: UInt32 = kAudioDeviceTransportTypeUnknown
var dataSize = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &type)
return status == noErr ? type : kAudioDeviceTransportTypeUnknown
}
private func fetchDefaultOutputDevice() -> AudioDeviceID {
var deviceID = AudioDeviceID()
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&dataSize,
&deviceID
)
return status == noErr ? deviceID : 0
}
private func setDefaultOutputDevice(_ deviceID: AudioDeviceID) {
var target = deviceID
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&target
)
if status == noErr {
DispatchQueue.main.async { [weak self] in
self?.activeDeviceID = deviceID
}
refreshDevices()
}
}
}

View file

@ -12,6 +12,7 @@ import AppKit
import Defaults
import SwiftUI
import IOBluetooth
import IOKit
/// Manages detection and monitoring of Bluetooth audio device connections
class BluetoothAudioManager: ObservableObject {
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer?
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
@Published private(set) var batteryStatus: [String: String] = [:]
private var batteryStatusByAddress: [String: Int] = [:]
private var batteryStatusByName: [String: Int] = [:]
private var missingBatteryLog: Set<String> = []
private var lastBatteryStatusUpdate: Date?
private let batteryStatusUpdateInterval: TimeInterval = 20
// MARK: - Initialization
private init() {
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
// Update connection state
isBluetoothAudioConnected = !connectedDevices.isEmpty
refreshBatteryLevelsForConnectedDevices()
if let lastDevice = connectedDevices.last {
lastConnectedDevice = lastDevice
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
@ -202,8 +214,14 @@ class BluetoothAudioManager: ObservableObject {
lastConnectedDevice = audioDevice
isBluetoothAudioConnected = true
refreshBatteryLevelsForConnectedDevices()
// Show HUD for new connection
showDeviceConnectedHUD(audioDevice)
if let refreshedDevice = connectedDevices.last {
showDeviceConnectedHUD(refreshedDevice)
} else {
showDeviceConnectedHUD(audioDevice)
}
}
}
}
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
}
isBluetoothAudioConnected = !connectedDevices.isEmpty
refreshBatteryLevelsForConnectedDevices()
}
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
/// Extracts battery level from Bluetooth device
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
// Try to read battery level from device
// Note: This may require additional permissions or may not be available for all devices
updateBatteryStatuses()
// Placeholder: Return nil if not available
// TODO: Implement actual battery level reading if device supports it
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
clearMissingBatteryInfo(for: device)
return level
}
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
clearMissingBatteryInfo(for: device)
return level
}
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
clearMissingBatteryInfo(for: device)
return level
}
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
clearMissingBatteryInfo(for: device)
return level
}
logMissingBatteryInfo(for: device)
return nil
}
@ -373,6 +411,590 @@ class BluetoothAudioManager: ObservableObject {
}
}
private func refreshBatteryLevelsForConnectedDevices() {
updateBatteryStatuses(force: true)
var updatedDevices: [BluetoothAudioDevice] = []
for device in connectedDevices {
let refreshedLevel =
batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
?? device.batteryLevel
let updatedDevice = device.withBatteryLevel(refreshedLevel)
updatedDevices.append(updatedDevice)
if let refreshedLevel {
clearMissingBatteryInfo(forName: device.name, address: device.address)
} else {
logMissingBatteryInfo(forName: device.name, address: device.address)
}
}
connectedDevices = updatedDevices
if let last = connectedDevices.last {
lastConnectedDevice = last
}
}
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
guard let address, !address.isEmpty else { return nil }
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
let normalizedTarget = normalizeBluetoothIdentifier(address)
var bestMatch: Int?
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
if let level = extractBatteryPercentage(from: payload) {
let clamped = clampBatteryPercentage(level)
bestMatch = max(bestMatch ?? clamped, clamped)
}
}
}
return bestMatch
}
private func batteryLevelFromDefaults(forName name: String) -> Int? {
guard !name.isEmpty else { return nil }
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
var bestMatch: Int?
for value in deviceCache.values {
guard let payload = value as? [String: Any] else { continue }
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
if let level = extractBatteryPercentage(from: payload) {
let clamped = clampBatteryPercentage(level)
bestMatch = max(bestMatch ?? clamped, clamped)
}
}
}
return bestMatch
}
private func batteryLevelFromRegistry(forName name: String) -> Int? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = normalizeProductName(trimmed)
guard !normalized.isEmpty else { return nil }
if let value = batteryStatusByName[normalized] {
return clampBatteryPercentage(value)
}
return nil
}
private func updateBatteryStatuses(force: Bool = false) {
let now = Date()
if !force, let lastBatteryStatusUpdate,
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
return
}
var combinedAddressPercentages: [String: Int] = [:]
var combinedNamePercentages: [String: Int] = [:]
let registry = collectRegistryBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
let defaults = collectDefaultsBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
let profiler = collectSystemProfilerBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
var statuses: [String: String] = [:]
for (key, value) in combinedAddressPercentages {
statuses[key] = String(clampBatteryPercentage(value))
}
let applyUpdates = {
self.batteryStatus = statuses
self.batteryStatusByAddress = combinedAddressPercentages
self.batteryStatusByName = combinedNamePercentages
self.lastBatteryStatusUpdate = now
}
if Thread.isMainThread {
applyUpdates()
} else {
DispatchQueue.main.sync(execute: applyUpdates)
}
}
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
guard !source.isEmpty else { return }
for (key, value) in source {
guard !key.isEmpty else { continue }
if let existing = target[key] {
target[key] = max(existing, value)
} else {
target[key] = value
}
}
}
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
var iterator = io_iterator_t()
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
let servicePort: mach_port_t
if #available(macOS 12.0, *) {
servicePort = kIOMainPortDefault
} else {
servicePort = kIOMasterPortDefault
}
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
if kernResult == KERN_SUCCESS {
var entry: io_object_t = IOIteratorNext(iterator)
while entry != 0 {
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
let normalizedPercent = clampBatteryPercentage(percent)
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
for key in identifierKeys {
if let identifier = stringValue(forKey: key, entry: entry) {
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
if !normalizedIdentifier.isEmpty {
if let existing = addressPercentages[normalizedIdentifier] {
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
} else {
addressPercentages[normalizedIdentifier] = normalizedPercent
}
}
}
}
let nameKeys = [
"Product",
"ProductName",
"DeviceName",
"Name",
"USB Product Name",
"Bluetooth Product Name"
]
for key in nameKeys {
if let product = stringValue(forKey: key, entry: entry) {
let normalizedName = normalizeProductName(product)
if !normalizedName.isEmpty {
if let existing = namePercentages[normalizedName] {
namePercentages[normalizedName] = max(existing, normalizedPercent)
} else {
namePercentages[normalizedName] = normalizedPercent
}
}
}
}
}
IOObjectRelease(entry)
entry = IOIteratorNext(iterator)
}
}
IOObjectRelease(iterator)
return (addressPercentages, namePercentages)
}
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
return ([:], [:])
}
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
guard let level = extractBatteryPercentage(from: payload) else { continue }
let clamped = clampBatteryPercentage(level)
let normalizedKey = normalizeBluetoothIdentifier(key)
if !normalizedKey.isEmpty {
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
}
for identifier in identifiersFromDeviceCachePayload(payload) {
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
}
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
let normalizedName = normalizeProductName(name)
if !normalizedName.isEmpty {
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
}
}
}
return (addressPercentages, namePercentages)
}
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
guard let root = systemProfilerBluetoothDictionary() else {
return ([:], [:])
}
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
for deviceGroup in connectedList {
for (rawName, payload) in deviceGroup {
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
let clamped = clampBatteryPercentage(percent)
let normalizedName = normalizeProductName(rawName)
if !normalizedName.isEmpty {
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
}
for address in profilerAddressCandidates(from: payload) {
let normalizedAddress = normalizeBluetoothIdentifier(address)
if !normalizedAddress.isEmpty {
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
}
}
}
}
}
return (addressPercentages, namePercentages)
}
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
var identifiers: Set<String> = []
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
for key in candidateKeys {
if let value = payload[key] as? String {
let normalized = normalizeBluetoothIdentifier(value)
if !normalized.isEmpty {
identifiers.insert(normalized)
}
} else if let data = payload[key] as? Data,
let ascii = String(data: data, encoding: .utf8) {
let normalized = normalizeBluetoothIdentifier(ascii)
if !normalized.isEmpty {
identifiers.insert(normalized)
}
}
}
return Array(identifiers)
}
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
let process = Process()
process.launchPath = "/usr/sbin/system_profiler"
process.arguments = ["SPBluetoothDataType", "-json"]
let outputPipe = Pipe()
process.standardOutput = outputPipe
let errorPipe = Pipe()
process.standardError = errorPipe
do {
try process.run()
} catch {
return nil
}
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
guard !data.isEmpty else { return nil }
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
let root = entries.first else {
return nil
}
return root
}
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
let preferredKeys = [
"device_batteryLevelCase",
"device_batteryLevelLeft",
"device_batteryLevelRight",
"device_batteryLevelMain",
"device_batteryLevel",
"device_batteryLevelCombined",
"device_batteryPercentCombined",
"Left Battery Level",
"Right Battery Level",
"Battery Level",
"BatteryPercent"
]
var values: [Int] = []
for key in preferredKeys {
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
if values.isEmpty {
for (key, raw) in payload where key.lowercased().contains("battery") {
if let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
}
let validValues = values.filter { $0 >= 0 }
return validValues.max()
}
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
var addresses: Set<String> = []
let keys = [
"device_address",
"device_mac_address",
"device_serial_num",
"device_serialNumber",
"device_serial_number"
]
for key in keys {
if let value = payload[key] as? String, !value.isEmpty {
addresses.insert(value)
} else if let data = payload[key] as? Data,
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
addresses.insert(ascii)
}
}
return Array(addresses)
}
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
guard let address, !address.isEmpty else { return nil }
let normalized = normalizeBluetoothIdentifier(address)
if let value = batteryStatusByAddress[normalized] {
return clampBatteryPercentage(value)
}
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
return clampBatteryPercentage(value)
}
return nil
}
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
let keys = [
"BatteryPercent",
"BatteryPercentCase",
"BatteryPercentLeft",
"BatteryPercentRight",
"BatteryPercentSingle",
"BatteryPercentCombined",
"BatteryPercentMain",
"device_batteryLevelLeft",
"device_batteryLevelRight",
"device_batteryLevelMain",
"Left Battery Level",
"Right Battery Level"
]
var values: [Int] = []
for key in keys {
guard let raw = payload[key] else { continue }
if let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
if values.isEmpty,
let services = payload["Services"] as? [[String: Any]] {
for service in services {
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
for value in serviceValues.values {
if let converted = convertToBatteryPercentage(value) {
values.append(converted)
}
}
}
}
}
return values.max()
}
private func convertToBatteryPercentage(_ value: Any) -> Int? {
if let number = value as? Int {
if number == 1 {
return 100
}
return number
}
if let number = value as? Double {
if number <= 1.0 {
return Int(number * 100)
}
return Int(number)
}
if let string = value as? String {
let trimmed = string.replacingOccurrences(of: "%", with: "")
if let doubleValue = Double(trimmed) {
if doubleValue <= 1.0 {
return Int(doubleValue * 100)
}
return Int(doubleValue)
}
}
return nil
}
private func clampBatteryPercentage(_ value: Int) -> Int {
min(max(value, 0), 100)
}
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
if normalizeBluetoothIdentifier(key) == normalizedTarget {
return true
}
let candidateFields: [String?] = [
payload["DeviceAddress"] as? String,
payload["Address"] as? String,
payload["BD_ADDR"] as? String,
payload["SerialNumber"] as? String
]
for field in candidateFields {
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
return true
}
}
if let deviceAddressData = payload["DeviceAddress"] as? Data,
let ascii = String(data: deviceAddressData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
if let addressData = payload["BD_ADDR"] as? Data,
let ascii = String(data: addressData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
if let serialData = payload["SerialNumber"] as? Data,
let ascii = String(data: serialData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
return false
}
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
let name = device.name ?? ""
let address = device.addressString ?? ""
logMissingBatteryInfo(forName: name, address: address)
}
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
let name = device.name ?? ""
let address = device.addressString ?? ""
clearMissingBatteryInfo(forName: name, address: address)
}
private func logMissingBatteryInfo(forName name: String, address: String) {
let key = missingBatteryKey(name: name, address: address)
guard !missingBatteryLog.contains(key) else { return }
missingBatteryLog.insert(key)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))")
}
private func clearMissingBatteryInfo(forName name: String, address: String) {
let key = missingBatteryKey(name: name, address: address)
missingBatteryLog.remove(key)
}
private func missingBatteryKey(name: String, address: String) -> String {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedName = normalizeProductName(trimmedName)
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
if normalizedName.isEmpty && normalizedAddress.isEmpty {
return "unknown"
}
return normalizedName + "#" + normalizedAddress
}
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
return nil
}
let value = unmanaged.takeRetainedValue()
if let string = value as? String, !string.isEmpty {
return string
}
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
return ascii
}
return nil
}
private func normalizeBluetoothIdentifier(_ value: String) -> String {
return value
.lowercased()
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: " ", with: "")
}
private func normalizeProductName(_ name: String) -> String {
let components = name
.lowercased()
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
return components.joined()
}
// MARK: - HUD Display
/// Shows HUD notification for newly connected audio device
@ -382,12 +1004,20 @@ class BluetoothAudioManager: ObservableObject {
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
// Convert battery percentage to 0.0-1.0 value
let batteryValue: CGFloat = if let battery = device.batteryLevel {
CGFloat(battery) / 100.0
let reportedBattery = device.batteryLevel
?? batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
let batteryValue: CGFloat = if let battery = reportedBattery {
CGFloat(clampBatteryPercentage(battery)) / 100.0
} else {
0.0 // 0 means battery info not available
}
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
// Show HUD via coordinator
coordinator.toggleSneakPeek(
status: true,
@ -411,16 +1041,47 @@ class BluetoothAudioManager: ObservableObject {
observers.removeAll()
cancellables.removeAll()
}
@MainActor
func refreshConnectedDeviceBatteries() {
refreshBatteryLevelsForConnectedDevices()
}
}
// MARK: - Models
struct BluetoothAudioDevice: Identifiable {
let id = UUID()
let id: UUID
let name: String
let address: String
let batteryLevel: Int? // 0-100, nil if not available
let deviceType: BluetoothAudioDeviceType
init(
id: UUID = UUID(),
name: String,
address: String,
batteryLevel: Int?,
deviceType: BluetoothAudioDeviceType
) {
self.id = id
self.name = name
self.address = address
self.batteryLevel = batteryLevel
self.deviceType = deviceType
}
}
extension BluetoothAudioDevice {
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
BluetoothAudioDevice(
id: id,
name: name,
address: address,
batteryLevel: batteryLevel,
deviceType: deviceType
)
}
}
enum BluetoothAudioDeviceType {

View file

@ -9,6 +9,7 @@ import SwiftUI
import AppKit
import SkyLightWindow
import Defaults
import QuartzCore
@MainActor
class LockScreenPanelManager {
@ -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 {
window.animator().setFrame(targetFrame, display: true)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.45
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
}
} else {
window.setFrame(targetFrame, display: true)
}
// 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() {

View file

@ -1,6 +1,7 @@
import Foundation
import Defaults
import CoreLocation
import Combine
@MainActor
final class LockScreenWeatherManager: ObservableObject {
@ -11,8 +12,17 @@ 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()
Task { @MainActor [weak self] in
guard let self else { return }
self.locationProvider.prepareAuthorization()
_ = await self.refresh(force: true)
}
}
func prepareLocationAccess() {
locationProvider.prepareAuthorization()
@ -28,9 +38,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 +57,428 @@ final class LockScreenWeatherManager: ObservableObject {
LockScreenWeatherPanelManager.shared.hide()
}
func refresh(force: Bool = false) async {
@discardableResult
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false")
let interval = Defaults[.lockScreenWeatherRefreshInterval]
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
if let snapshot = snapshot {
LockScreenWeatherPanelManager.shared.update(with: snapshot)
let remaining = interval - Date().timeIntervalSince(lastFetchDate)
NSLog("LockScreenWeatherManager: skipping refresh (%.0f s remaining until next fetch)", max(remaining, 0))
if let payload = latestWeatherPayload {
if Defaults[.lockScreenWeatherShowsBluetooth] {
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
}
let snapshot = makeSnapshot(from: payload)
self.snapshot = snapshot
deliver(snapshot, forceShow: false)
return snapshot
} else if let snapshot = snapshot {
deliver(snapshot, forceShow: false)
return snapshot
}
return
return snapshot
}
do {
let location = await locationProvider.currentLocation()
let snapshot = try await provider.fetchSnapshot(location: location)
let provider = Defaults[.lockScreenWeatherProviderSource]
NSLog(
"LockScreenWeatherManager: fetching weather from %@ (location %@)",
provider.displayName,
location != nil ? "available" : "missing"
)
let payload = try await fetchWeatherPayload(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)
NSLog("LockScreenWeatherManager: weather refresh succeeded")
return snapshot
} catch {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let showsCharging = Defaults[.lockScreenWeatherShowsCharging]
let chargingInfo = showsCharging ? makeChargingInfo() : nil
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill",
description: snapshot?.description ?? "",
locationName: snapshot?.locationName,
charging: chargingInfo,
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
battery: batteryInfo,
showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
showsChargingPercentage: Defaults[.lockScreenWeatherShowsChargingPercentage],
temperatureInfo: snapshot?.temperatureInfo,
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint]
)
self.snapshot = fallback
deliver(fallback, forceShow: false)
return fallback
}
}
private func fetchWeatherPayload(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
let primarySource = Defaults[.lockScreenWeatherProviderSource]
do {
return try await provider.fetchSnapshot(location: location, source: primarySource)
} catch {
if primarySource == .openMeteo {
NSLog("LockScreenWeatherManager: Open Meteo fetch failed - %@. Falling back to wttr.in", error.localizedDescription)
do {
return try await provider.fetchSnapshot(location: location, source: .wttr)
} catch {
NSLog("LockScreenWeatherManager: wttr.in fallback also failed - %@", error.localizedDescription)
throw error
}
}
throw error
}
}
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(.lockScreenWeatherShowsChargingPercentage, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
.map { _ in () }.eraseToAnyPublisher()
]
Publishers.MergeMany(defaultsPublishers)
.sink { [weak self] in
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
}
.store(in: &cancellables)
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
NSLog("LockScreenWeatherManager: provider changed, forcing refresh")
_ = await self.refresh(force: true)
}
}
.store(in: &cancellables)
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
NSLog("LockScreenWeatherManager: temperature unit changed, forcing refresh")
_ = await self.refresh(force: true)
}
}
.store(in: &cancellables)
Defaults.publisher(.enableLockScreenWeatherWidget, options: [])
.sink { [weak self] change in
guard let self else { return }
Task { @MainActor in
if change.newValue {
NSLog("LockScreenWeatherManager: widget enabled, triggering refresh")
self.locationProvider.prepareAuthorization()
_ = await self.refresh(force: true)
} else {
LockScreenWeatherPanelManager.shared.hide()
}
}
}
.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 chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot(
temperatureText: payload.temperatureText,
symbolName: payload.symbolName,
description: payload.description,
locationName: locationName,
charging: chargingInfo,
bluetooth: bluetoothInfo,
battery: batteryInfo,
showsLocation: shouldShowLocation,
airQuality: airQualityInfo,
widgetStyle: widgetStyle,
showsChargingPercentage: showsChargingPercentage,
temperatureInfo: payload.temperatureInfo,
usesGaugeTint: usesGaugeTint
)
}
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
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
return LockScreenWeatherSnapshot.ChargingInfo(
minutesRemaining: remaining,
isCharging: isCharging,
isPluggedIn: isPluggedIn,
batteryLevel: isPluggedIn || isCharging ? clampedLevel : nil
)
}
private func makeBatteryGaugeInfo(isCharging: Bool) -> LockScreenWeatherSnapshot.BatteryInfo? {
guard !isCharging else { return nil }
let battery = BatteryStatusViewModel.shared
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
guard clampedLevel >= 0 else { return nil }
return LockScreenWeatherSnapshot.BatteryInfo(batteryLevel: clampedLevel)
}
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 TemperatureInfo: Equatable {
let current: Double
let minimum: Double?
let maximum: Double?
let unitSymbol: String
var displayMinimum: String? {
guard let minimum else { return nil }
return Self.formatted(value: minimum)
}
var displayMaximum: String? {
guard let maximum else { return nil }
return Self.formatted(value: maximum)
}
var displayCurrent: String {
Self.formatted(value: current)
}
private static func formatted(value: Double) -> String {
let rounded = Int(round(value))
return "\(rounded)"
}
}
struct ChargingInfo: Equatable {
let minutesRemaining: Int?
let isCharging: Bool
let isPluggedIn: Bool
let batteryLevel: Int?
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
}
struct BatteryInfo: Equatable {
let batteryLevel: Int
}
struct AirQualityInfo: Equatable {
enum Category: String, Equatable {
case good
case moderate
case unhealthyForSensitive
case unhealthy
case veryUnhealthy
case hazardous
case unknown
var displayName: String {
switch self {
case .good: return "Good"
case .moderate: return "Moderate"
case .unhealthyForSensitive: return "Sensitive"
case .unhealthy: return "Unhealthy"
case .veryUnhealthy: return "Very Unhealthy"
case .hazardous: return "Hazardous"
case .unknown: return "Unknown"
}
}
}
let index: Int
let category: Category
}
let temperatureText: String
let symbolName: String
let description: String
let locationName: String?
let charging: ChargingInfo?
let bluetooth: BluetoothInfo?
let battery: BatteryInfo?
let showsLocation: Bool
let airQuality: AirQualityInfo?
let widgetStyle: LockScreenWeatherWidgetStyle
let showsChargingPercentage: Bool
let temperatureInfo: TemperatureInfo?
let usesGaugeTint: Bool
}
extension LockScreenWeatherSnapshot.AirQualityInfo.Category {
init(aqiValue: Int) {
switch aqiValue {
case ..<0:
self = .unknown
case 0...50:
self = .good
case 51...100:
self = .moderate
case 101...150:
self = .unhealthyForSensitive
case 151...200:
self = .unhealthy
case 201...300:
self = .veryUnhealthy
case 301...:
self = .hazardous
default:
self = .unknown
}
}
}
private actor LockScreenWeatherProvider {
@ -82,6 +494,23 @@ private actor LockScreenWeatherProvider {
}
func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
let source = Defaults[.lockScreenWeatherProviderSource]
return try await fetchSnapshot(location: location, source: source)
}
func fetchSnapshot(location: CLLocation?, source: LockScreenWeatherProviderSource) async throws -> LockScreenWeatherSnapshot {
switch source {
case .wttr:
return try await fetchWttrSnapshot(location: location)
case .openMeteo:
guard let location else {
return try await fetchWttrSnapshot(location: nil)
}
return try await fetchOpenMeteoSnapshot(location: location)
}
}
private func fetchWttrSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
let locationSuffix: String
if let coordinate = location?.coordinate {
let lat = String(format: "%.4f", coordinate.latitude)
@ -91,7 +520,8 @@ private actor LockScreenWeatherProvider {
locationSuffix = ""
}
let urlString = locationSuffix.isEmpty ? "https://wttr.in/?format=j1" : "https://wttr.in/\(locationSuffix)?format=j1"
let query = "?format=j1&aqi=yes"
let urlString = locationSuffix.isEmpty ? "https://wttr.in/\(query)" : "https://wttr.in/\(locationSuffix)\(query)"
guard let url = URL(string: urlString) else {
throw WeatherProviderError.invalidURL
@ -107,9 +537,29 @@ private actor LockScreenWeatherProvider {
throw WeatherProviderError.noData
}
let usesMetric = Locale.current.usesMetricSystem
let temperature = usesMetric ? condition.tempC : condition.tempF
let temperatureText = "\(temperature)°"
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
let rawTemperature = usesMetric ? condition.tempC : condition.tempF
let temperatureValue = Double(rawTemperature) ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°"
let unitSymbol = unit.symbol
let forecast = payload.dailyWeather.first
let minTempValue = forecast.flatMap { daily in
let value = usesMetric ? daily.mintempC : daily.mintempF
return value.flatMap(Double.init)
}
let maxTempValue = forecast.flatMap { daily in
let value = usesMetric ? daily.maxtempC : daily.maxtempF
return value.flatMap(Double.init)
}
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
minimum: minTempValue,
maximum: maxTempValue,
unitSymbol: unitSymbol
)
let code = Int(condition.weatherCode) ?? 113
let symbol = WeatherSymbolMapper.symbol(for: code)
@ -118,11 +568,137 @@ private actor LockScreenWeatherProvider {
let nearest = payload.nearestArea.first
let locationName = nearest?.preferredName
let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if let index = condition.airQuality?.usIndexValue {
airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo(
index: index,
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
)
} else {
airQualityInfo = nil
}
return LockScreenWeatherSnapshot(
temperatureText: temperatureText,
symbolName: symbol,
description: description,
locationName: locationName
locationName: locationName,
charging: nil,
bluetooth: nil,
battery: nil,
showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
)
}
private func fetchOpenMeteoSnapshot(location: CLLocation) async throws -> LockScreenWeatherSnapshot {
let latitude = String(format: "%.4f", location.coordinate.latitude)
let longitude = String(format: "%.4f", location.coordinate.longitude)
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
var weatherQueryItems: [URLQueryItem] = [
URLQueryItem(name: "latitude", value: latitude),
URLQueryItem(name: "longitude", value: longitude),
URLQueryItem(name: "current", value: "temperature_2m,weather_code,pressure_msl"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
URLQueryItem(name: "forecast_days", value: "1"),
URLQueryItem(name: "timezone", value: "auto")
]
if let temperatureUnitParameter = unit.openMeteoTemperatureParameter {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter))
}
weatherComponents?.queryItems = weatherQueryItems
guard let weatherURL = weatherComponents?.url else {
throw WeatherProviderError.invalidURL
}
let (weatherData, weatherResponse) = try await session.data(from: weatherURL)
guard let weatherHTTP = weatherResponse as? HTTPURLResponse, (200..<300).contains(weatherHTTP.statusCode) else {
throw WeatherProviderError.invalidResponse
}
let weatherDecoder = JSONDecoder()
weatherDecoder.keyDecodingStrategy = .convertFromSnakeCase
let weatherPayload = try weatherDecoder.decode(OpenMeteoForecastResponse.self, from: weatherData)
guard let current = weatherPayload.current else {
throw WeatherProviderError.noData
}
let temperatureValue = current.temperature2M ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let unitSymbol = unit.symbol
let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
minimum: minTempValue,
maximum: maxTempValue,
unitSymbol: unitSymbol
)
var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if Defaults[.lockScreenWeatherShowsAQI] {
airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude)
}
return LockScreenWeatherSnapshot(
temperatureText: temperatureText,
symbolName: mapping.symbol,
description: mapping.description,
locationName: nil,
charging: nil,
bluetooth: nil,
battery: nil,
showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
)
}
private func fetchOpenMeteoAirQuality(latitude: String, longitude: String) async throws -> LockScreenWeatherSnapshot.AirQualityInfo? {
var airComponents = URLComponents(string: "https://air-quality-api.open-meteo.com/v1/air-quality")
airComponents?.queryItems = [
URLQueryItem(name: "latitude", value: latitude),
URLQueryItem(name: "longitude", value: longitude),
URLQueryItem(name: "current", value: "us_aqi"),
URLQueryItem(name: "timezone", value: "auto")
]
guard let airURL = airComponents?.url else {
throw WeatherProviderError.invalidURL
}
let (airData, airResponse) = try await session.data(from: airURL)
guard let airHTTP = airResponse as? HTTPURLResponse, (200..<300).contains(airHTTP.statusCode) else {
throw WeatherProviderError.invalidResponse
}
let airDecoder = JSONDecoder()
airDecoder.keyDecodingStrategy = .convertFromSnakeCase
let airPayload = try airDecoder.decode(OpenMeteoAirQualityResponse.self, from: airData)
guard let airCurrent = airPayload.current, let indexValue = airCurrent.usAqi else {
return nil
}
let index = Int(round(indexValue))
return LockScreenWeatherSnapshot.AirQualityInfo(
index: index,
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
)
}
}
@ -136,9 +712,11 @@ enum WeatherProviderError: Error {
private struct WTTRResponse: Decodable {
let current_condition: [WTTRCurrentCondition]
let nearest_area: [WTTRNearestArea]?
let weather: [WTTRDailyWeather]?
var currentCondition: [WTTRCurrentCondition] { current_condition }
var nearestArea: [WTTRNearestArea] { nearest_area ?? [] }
var dailyWeather: [WTTRDailyWeather] { weather ?? [] }
}
private struct WTTRCurrentCondition: Decodable {
@ -148,6 +726,9 @@ private struct WTTRCurrentCondition: Decodable {
case weatherCode
case weatherDesc
case langEn = "lang_en"
case pressure = "pressure"
case pressureInches = "pressureInches"
case airQuality = "air_quality"
}
let tempC: String
@ -155,6 +736,9 @@ private struct WTTRCurrentCondition: Decodable {
let weatherCode: String
let weatherDesc: [WTTRTextValue]?
let langEn: [WTTRTextValue]?
let pressure: String?
let pressureInches: String?
let airQuality: WTTRAirQuality?
var localizedDescription: String {
if let english = langEn?.first?.value, !english.isEmpty {
@ -171,6 +755,35 @@ private struct WTTRTextValue: Decodable {
let value: String
}
private struct WTTRAirQuality: Decodable {
private enum CodingKeys: String, CodingKey {
case usEpaIndex = "us-epa-index"
case gbDefraIndex = "gb-defra-index"
}
let usEpaIndex: String?
let gbDefraIndex: String?
var usIndexValue: Int? {
guard let value = usEpaIndex else { return nil }
return Int(value)
}
}
private struct WTTRDailyWeather: Decodable {
private enum CodingKeys: String, CodingKey {
case maxtempC = "maxtempC"
case maxtempF = "maxtempF"
case mintempC = "mintempC"
case mintempF = "mintempF"
}
let maxtempC: String?
let maxtempF: String?
let mintempC: String?
let mintempF: String?
}
private struct WTTRNearestArea: Decodable {
let areaName: [WTTRTextValue]?
let region: [WTTRTextValue]?
@ -196,6 +809,69 @@ private struct WTTRNearestArea: Decodable {
}
}
private struct OpenMeteoForecastResponse: Decodable {
struct Current: Decodable {
let time: String?
let temperature2M: Double?
let weatherCode: Int?
let pressureMsl: Double?
}
let current: Current?
let daily: Daily?
struct Daily: Decodable {
let temperature2MMax: [Double]?
let temperature2MMin: [Double]?
}
}
private struct OpenMeteoAirQualityResponse: Decodable {
struct Current: Decodable {
let time: String?
let usAqi: Double?
}
let current: Current?
}
private enum OpenMeteoSymbolMapper {
static func mapping(for code: Int) -> (symbol: String, description: String) {
switch code {
case 0:
return ("sun.max.fill", "Clear sky")
case 1:
return ("cloud.sun.fill", "Mainly clear")
case 2:
return ("cloud.sun.fill", "Partly cloudy")
case 3:
return ("cloud.fill", "Overcast")
case 45, 48:
return ("cloud.fog.fill", "Fog")
case 51, 53, 55:
return ("cloud.drizzle.fill", "Drizzle")
case 56, 57:
return ("cloud.sleet.fill", "Freezing drizzle")
case 61, 63, 65:
return ("cloud.rain.fill", "Rain")
case 66, 67:
return ("cloud.sleet.fill", "Freezing rain")
case 71, 73, 75, 77:
return ("cloud.snow.fill", "Snow")
case 80, 81, 82:
return ("cloud.heavyrain.fill", "Rain showers")
case 85, 86:
return ("cloud.snow.fill", "Snow showers")
case 95:
return ("cloud.bolt.rain.fill", "Thunderstorm")
case 96, 99:
return ("cloud.bolt.rain.fill", "Thunderstorm with hail")
default:
return ("cloud.sun.fill", "Cloudy")
}
}
}
private enum WeatherSymbolMapper {
static func symbol(for code: Int) -> String {
switch code {

View file

@ -80,7 +80,9 @@ final class LockScreenWeatherPanelManager {
let originX = screenFrame.midX - (size.width / 2)
let verticalOffset = screenFrame.height * 0.15
let maxY = screenFrame.maxY - size.height - 48
let originY = min(maxY, screenFrame.midY + verticalOffset)
return NSRect(x: originX, y: originY, width: size.width, height: size.height)
let baseY = min(maxY, screenFrame.midY + verticalOffset)
let loweredY = baseY - 36
let clampedY = max(screenFrame.minY + 80, loweredY)
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)
}
}

View file

@ -0,0 +1,65 @@
import Foundation
import IOKit.ps
/// Lightweight helper for querying macOS battery charging status and ETA.
final class MacBatteryManager {
static let shared = MacBatteryManager()
private init() {}
struct BatteryStatus {
let timeRemainingMinutes: Int?
let isCharging: Bool
let percentage: Int?
}
func currentStatus() -> BatteryStatus {
guard let sourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let sourcesList = IOPSCopyPowerSourcesList(sourcesInfo)?.takeRetainedValue() as? [CFTypeRef] else {
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
}
for source in sourcesList {
guard let description = IOPSGetPowerSourceDescription(sourcesInfo, source)?.takeUnretainedValue() as? [String: Any],
let type = description[kIOPSTypeKey] as? String,
type == kIOPSInternalBatteryType else {
continue
}
let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
let timeRemaining = description[kIOPSTimeToFullChargeKey] as? Int
let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int
let maxCapacity = description[kIOPSMaxCapacityKey] as? Int
let percentage: Int?
if let current = currentCapacity, let max = maxCapacity, max > 0 {
percentage = (current * 100) / max
} else {
percentage = nil
}
return BatteryStatus(
timeRemainingMinutes: timeRemaining,
isCharging: isCharging,
percentage: percentage
)
}
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
}
func formattedTimeToFullCharge() -> String? {
let status = currentStatus()
guard status.isCharging, let minutes = status.timeRemainingMinutes, minutes > 0 else {
return nil
}
let hours = minutes / 60
let remainingMinutes = minutes % 60
if hours > 0 {
return "\(hours)h \(remainingMinutes)m"
}
return "\(remainingMinutes)m"
}
}

View file

@ -93,6 +93,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
// MARK: - HUD Dispatch
private func sendVolumeNotification(value: Float) {
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
return
}
coordinator?.toggleSneakPeek(
status: true,
type: .volume,

View file

@ -9,6 +9,38 @@ extension Notification.Name {
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
}
final class HUDSuppressionCoordinator {
static let shared = HUDSuppressionCoordinator()
private let queue = DispatchQueue(label: "com.dynamicisland.hud-suppression")
private var volumeSuppressedUntil: Date?
func suppressVolumeHUD(for interval: TimeInterval) {
guard interval > 0 else { return }
queue.sync {
let proposed = Date().addingTimeInterval(interval)
if let current = volumeSuppressedUntil {
volumeSuppressedUntil = max(current, proposed)
} else {
volumeSuppressedUntil = proposed
}
}
}
var shouldSuppressVolumeHUD: Bool {
queue.sync {
guard let expiration = volumeSuppressedUntil else {
return false
}
if Date() < expiration {
return true
}
volumeSuppressedUntil = nil
return false
}
}
}
final class SystemVolumeController {
static let shared = SystemVolumeController()
@ -46,6 +78,10 @@ final class SystemVolumeController {
}
func adjust(by delta: Float) {
guard delta != 0 else { return }
if isMuted {
setMuted(false)
}
var newValue = currentVolume + delta
newValue = max(0, min(1, newValue))
setVolume(newValue)
@ -64,18 +100,53 @@ final class SystemVolumeController {
}
func setVolume(_ value: Float) {
var volume = max(0, min(1, value))
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
if status != noErr {
NSLog("⚠️ Failed to set volume: \(status)")
let clamped = max(0, min(1, value))
if isMuted && clamped > 0 {
setMuted(false)
}
let elements = volumeElements()
if elements.isEmpty {
var volume = clamped
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
if status != noErr {
NSLog("⚠️ Failed to set volume: \(status)")
}
} else {
for element in elements {
var volume = clamped
let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume)
if status != noErr {
NSLog("⚠️ Failed to set volume for element \(element): \(status)")
} else {
cache(element: element, for: kAudioDevicePropertyVolumeScalar)
}
}
}
notifyCurrentState()
}
func setMuted(_ muted: Bool) {
var muteFlag: UInt32 = muted ? 1 : 0
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
if status != noErr {
NSLog("⚠️ Failed to set mute state: \(status)")
let elements = muteElements()
if elements.isEmpty {
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
if status != noErr {
NSLog("⚠️ Failed to set mute state: \(status)")
}
return
}
for element in elements {
var value = muteFlag
let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value)
if status != noErr {
NSLog("⚠️ Failed to set mute state for element \(element): \(status)")
} else {
cache(element: element, for: kAudioDevicePropertyMute)
}
}
}
@ -159,22 +230,85 @@ final class SystemVolumeController {
}
private func getVolume() -> Float {
var volume = Float32(0)
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
if status != noErr {
NSLog("⚠️ Unable to fetch volume: \(status)")
let elements = volumeElements()
if elements.isEmpty {
var volume = Float32(0)
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
if status != noErr {
NSLog("⚠️ Unable to fetch volume: \(status)")
}
return volume
}
return volume
var masterVolume: Float?
var accumulator: Float = 0
var count: Float = 0
for element in elements {
var value = Float32(0)
let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value)
if status == noErr {
if element == kAudioObjectPropertyElementMaster {
masterVolume = value
}
accumulator += value
count += 1
}
}
if let masterVolume {
return masterVolume
}
if count > 0 {
return accumulator / count
}
var fallback = Float32(0)
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback)
if status != noErr {
NSLog("⚠️ Unable to fetch fallback volume: \(status)")
}
return fallback
}
private func getMuteState() -> Bool {
var mute: UInt32 = 0
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
let elements = muteElements()
if elements.isEmpty {
var mute: UInt32 = 0
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
if status != noErr {
return false
}
return mute != 0
}
var retrieved = false
var allMuted = true
for element in elements {
var value: UInt32 = 0
let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value)
if status == noErr {
retrieved = true
if value == 0 {
allMuted = false
}
}
}
if retrieved {
return allMuted
}
var fallback: UInt32 = 0
let status = getData(selector: kAudioDevicePropertyMute, data: &fallback)
if status != noErr {
// Some devices do not support mute; treat as not muted
return false
}
return mute != 0
return fallback != 0
}
private func refreshPropertyElements() {
@ -264,6 +398,38 @@ final class SystemVolumeController {
}
return lastStatus
}
private func getData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
var address = makeAddress(selector: selector, element: element)
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
return kAudioHardwareUnknownPropertyError
}
var size = UInt32(MemoryLayout<T>.size)
return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
}
private func setData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
var address = makeAddress(selector: selector, element: element)
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
return kAudioHardwareUnknownPropertyError
}
let size = UInt32(MemoryLayout<T>.size)
return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
}
private func volumeElements() -> [AudioObjectPropertyElement] {
candidateElements.filter { element in
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
return propertyExists(deviceID: currentDeviceID, address: &address)
}
}
private func muteElements() -> [AudioObjectPropertyElement] {
candidateElements.filter { element in
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
return propertyExists(deviceID: currentDeviceID, address: &address)
}
}
}
final class SystemBrightnessController {
@ -275,9 +441,17 @@ final class SystemBrightnessController {
private var observers: [NSObjectProtocol] = []
private var notificationsInstalled = false
private var displayID: CGDirectDisplayID = CGMainDisplayID()
private var brightnessAnimationTimer: Timer?
private var brightnessAnimationStart: Float = 0
private var brightnessAnimationTarget: Float = 0
private var brightnessAnimationStartDate: Date?
private let brightnessAnimationDuration: TimeInterval = 0.18
private let brightnessAnimationSteps = 10
private var lastEmittedBrightness: Float = 0.5
private init() {
registerExternalNotifications()
lastEmittedBrightness = currentBrightness
}
func start() {
@ -286,6 +460,8 @@ final class SystemBrightnessController {
func stop() {
onBrightnessChange = nil
brightnessAnimationTimer?.invalidate()
brightnessAnimationTimer = nil
}
func adjust(by delta: Float) {
@ -294,17 +470,8 @@ final class SystemBrightnessController {
func setBrightness(_ value: Float) {
let clamped = max(0, min(1, value))
if setBrightnessViaDisplayServices(clamped) {
notifyCurrentBrightness()
return
}
guard let service = displayService() else { return }
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
IOObjectRelease(service)
if status == kIOReturnSuccess {
notifyCurrentBrightness()
} else {
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
DispatchQueue.main.async {
self.beginBrightnessAnimation(to: clamped)
}
}
@ -324,10 +491,77 @@ final class SystemBrightnessController {
private func notifyCurrentBrightness() {
let brightness = currentBrightness
DispatchQueue.main.async {
self.onBrightnessChange?(brightness)
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness])
emitBrightnessChange(value: brightness)
}
private func beginBrightnessAnimation(to target: Float) {
brightnessAnimationTimer?.invalidate()
let start = lastEmittedBrightness
if abs(start - target) <= 0.0005 {
applyBrightness(target)
emitBrightnessChange(value: target)
return
}
brightnessAnimationStart = start
brightnessAnimationTarget = target
brightnessAnimationStartDate = Date()
let interval = brightnessAnimationDuration / Double(brightnessAnimationSteps)
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in
guard let self else { return }
guard let startDate = self.brightnessAnimationStartDate else {
timer.invalidate()
self.brightnessAnimationTimer = nil
return
}
let elapsed = Date().timeIntervalSince(startDate)
let progress = min(elapsed / self.brightnessAnimationDuration, 1)
let eased = self.ease(progress)
let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased)
self.applyBrightness(value)
self.emitBrightnessChange(value: value)
if progress >= 1 {
timer.invalidate()
self.brightnessAnimationTimer = nil
}
}
brightnessAnimationTimer = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
private func applyBrightness(_ value: Float) {
let clamped = max(0, min(1, value))
if setBrightnessViaDisplayServices(clamped) {
return
}
guard let service = displayService() else { return }
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
IOObjectRelease(service)
if status != kIOReturnSuccess {
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
}
}
private func emitBrightnessChange(value: Float) {
let clamped = max(0, min(1, value))
lastEmittedBrightness = clamped
let dispatchBlock = {
self.onBrightnessChange?(clamped)
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": clamped])
}
if Thread.isMainThread {
dispatchBlock()
} else {
DispatchQueue.main.async(execute: dispatchBlock)
}
}
private func ease(_ t: Double) -> Double {
let clamped = min(max(t, 0), 1)
return 1 - pow(1 - clamped, 3)
}
private func displayService() -> io_service_t? {
@ -386,6 +620,7 @@ final class SystemBrightnessController {
}
deinit {
brightnessAnimationTimer?.invalidate()
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
}
}

View file

@ -0,0 +1,199 @@
#if os(macOS)
import AppKit
import SwiftUI
import SkyLightWindow
import QuartzCore
struct TimerControlWindowMetrics {
let notchHeight: CGFloat
let notchWidth: CGFloat
let rightWingWidth: CGFloat
let cornerRadius: CGFloat
let spacing: CGFloat
}
@MainActor
final class TimerControlWindowManager {
static let shared = TimerControlWindowManager()
private var window: NSWindow?
private var hostingView: NSHostingView<TimerControlOverlay>?
private var hasDelegated = false
private var lastMetrics: TimerControlWindowMetrics?
private init() {}
@discardableResult
func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
guard let screen = resolveScreen(from: viewModel) else { return false }
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
hide()
return false
}
let overlay = TimerControlOverlay(
notchHeight: metrics.notchHeight,
cornerRadius: metrics.cornerRadius
)
let hosting = ensureHostingView(with: overlay)
let fittingSize = measuredSize(for: hosting)
hosting.frame = NSRect(origin: .zero, size: fittingSize)
let window = ensureWindow(on: screen)
if window.contentView !== hosting {
window.contentView = hosting
}
let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
lastMetrics = metrics
if !hasDelegated {
SkyLightOperator.shared.delegateWindow(window)
hasDelegated = true
}
if window.alphaValue <= 0.01 {
let startFrame = initialFrame(for: targetFrame, metrics: metrics)
window.setFrame(startFrame, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.24
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(targetFrame, display: true)
window.animator().alphaValue = 1
} completionHandler: {
window.setFrame(targetFrame, display: false)
}
} else {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
} completionHandler: {
window.setFrame(targetFrame, display: false)
}
window.orderFrontRegardless()
window.alphaValue = 1
}
return true
}
@discardableResult
func refresh(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
guard window != nil else {
return present(using: viewModel, metrics: metrics)
}
return present(using: viewModel, metrics: metrics)
}
func hide(animated: Bool = true) {
guard let window else { return }
guard animated, window.alphaValue > 0.01 else {
window.orderOut(nil)
window.alphaValue = 0
return
}
let retreatFrame = notchRetreatFrame(from: window.frame)
let originalFrame = window.frame
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().setFrame(retreatFrame, display: true)
window.animator().alphaValue = 0
} completionHandler: {
window.setFrame(originalFrame, display: false)
window.orderOut(nil)
window.alphaValue = 0
}
}
private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView<TimerControlOverlay> {
if let hostingView {
hostingView.rootView = overlay
return hostingView
}
let view = NSHostingView(rootView: overlay)
hostingView = view
return view
}
private func ensureWindow(on screen: NSScreen) -> NSWindow {
if let window {
return window
}
let window = NSWindow(
contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 220, height: 64),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = false
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window.ignoresMouseEvents = false
window.isMovable = false
window.alphaValue = 0
self.window = window
self.hasDelegated = false
return window
}
private func measuredSize(for hosting: NSHostingView<TimerControlOverlay>) -> CGSize {
hosting.layoutSubtreeIfNeeded()
let size = hosting.fittingSize
return CGSize(width: ceil(size.width), height: ceil(size.height))
}
private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
if let screenName = viewModel.screen,
let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
return targetScreen
}
return NSScreen.main
}
private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: TimerControlWindowMetrics) -> NSRect {
let screenFrame = screen.frame
let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
let originY = screenFrame.maxY - size.height
let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
let rawOriginX = rightEdge + metrics.spacing
let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
}
private func initialFrame(for targetFrame: NSRect, metrics: TimerControlWindowMetrics) -> NSRect {
var frame = targetFrame
let notchRightEdge = targetFrame.minX - metrics.spacing
frame.origin.x = notchRightEdge - targetFrame.width
return frame
}
private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
guard let metrics = lastMetrics else {
return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
}
let notchRightEdge = currentFrame.minX - metrics.spacing
var frame = currentFrame
frame.origin.x = notchRightEdge - currentFrame.width
return frame
}
}
#endif

View file

@ -10,6 +10,7 @@ import Combine
import SwiftUI
import AVFoundation
import AppKit
import Defaults
class TimerManager: ObservableObject {
// MARK: - Properties
@ -24,6 +25,7 @@ class TimerManager: ObservableObject {
@Published var isFinished: Bool = false
@Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative
@Published var lastUpdated: Date = .distantPast
@Published var activePresetId: UUID?
// Timer progress (0.0 to 1.0, or >1.0 for overtime)
var progress: Double {
@ -124,7 +126,7 @@ class TimerManager: ObservableObject {
}
// MARK: - Timer Methods
func startTimer(duration: TimeInterval, name: String = "Timer") {
func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) {
// Stop any existing timer
timerInstance?.invalidate()
@ -141,7 +143,7 @@ class TimerManager: ObservableObject {
isPaused = false
lastUpdated = Date()
triggerTimerSneakPeek()
activePresetId = preset?.id
// Start countdown timer
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@ -250,6 +252,13 @@ class TimerManager: ObservableObject {
isPaused = false
isFinished = false
isOvertime = false
activePresetId = nil
}
// MARK: - Derived State
var activePreset: TimerPreset? {
guard let presetId = activePresetId else { return nil }
return Defaults[.timerPresets].first { $0.id == presetId }
}
private func scheduleSmoothClose() {
@ -297,21 +306,6 @@ class TimerManager: ObservableObject {
}
}
private func triggerTimerSneakPeek() {
let coordinator = DynamicIslandViewCoordinator.shared
DispatchQueue.main.async {
coordinator.sneakPeek.show = true
coordinator.sneakPeek.type = .timer
coordinator.sneakPeek.value = CGFloat(self.progress) // Use timer progress (0.0 to 1.0)
// Hide sneak peek after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
coordinator.sneakPeek.show = false
}
}
}
// MARK: - Formatted Time Strings
func formattedRemainingTime() -> String {
if isOvertime && remainingTime < 0 {

View file

@ -196,6 +196,27 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
var id: String { self.rawValue }
}
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
case adaptive = "Adaptive"
case solid = "Solid"
var id: String { rawValue }
var displayName: String {
switch self {
case .adaptive: return "Adaptive gradient"
case .solid: return "Solid colour"
}
}
}
enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable {
case bar = "Bar"
case ring = "Ring"
var id: String { rawValue }
}
// AI Model types for screen assistant
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
case gemini = "Gemini"
@ -334,10 +355,21 @@ 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 lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
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 +394,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 +419,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)
@ -399,6 +435,14 @@ extension Defaults.Keys {
// MARK: Timer Feature
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
static let timerShowsCountdown = Key<Bool>("timerShowsCountdown", default: true)
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)

View file

@ -32,6 +32,8 @@ 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 isTimerPopoverActive: Bool = false
@Published var shouldRecheckHover: Bool = false
let webcamManager = WebcamManager.shared

View file

@ -0,0 +1,93 @@
//
// TimerPreset.swift
// DynamicIsland
//
// Created by GitHub Copilot on 2025-11-01.
//
import SwiftUI
import AppKit
import Defaults
struct TimerPreset: Identifiable, Codable, Hashable, Defaults.Serializable {
struct ColorData: Codable, Hashable, Defaults.Serializable {
var red: Double
var green: Double
var blue: Double
var alpha: Double
init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
init(color: Color) {
let nsColor = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color)
self.red = nsColor.redComponent
self.green = nsColor.greenComponent
self.blue = nsColor.blueComponent
self.alpha = nsColor.alphaComponent
}
var color: Color {
Color(red: red, green: green, blue: blue, opacity: alpha)
}
}
struct DurationComponents {
var hours: Int
var minutes: Int
var seconds: Int
}
var id: UUID
var name: String
var duration: TimeInterval
var colorData: ColorData
init(id: UUID = UUID(), name: String, duration: TimeInterval, colorData: ColorData) {
self.id = id
self.name = name
self.duration = duration
self.colorData = colorData
}
init(id: UUID = UUID(), name: String, duration: TimeInterval, color: Color) {
self.init(id: id, name: name, duration: duration, colorData: ColorData(color: color))
}
var color: Color {
colorData.color
}
mutating func updateColor(_ newColor: Color) {
colorData = ColorData(color: newColor)
}
var formattedDuration: String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
return formatter.string(from: duration) ?? "0:00"
}
static func components(for duration: TimeInterval) -> DurationComponents {
let totalSeconds = max(0, Int(duration))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
return DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
}
static func duration(from components: DurationComponents) -> TimeInterval {
TimeInterval(components.hours * 3600 + components.minutes * 60 + components.seconds)
}
static let defaultPresets: [TimerPreset] = [
TimerPreset(name: "Focus", duration: 25 * 60, color: Color.orange),
TimerPreset(name: "Break", duration: 5 * 60, color: Color.green),
TimerPreset(name: "Deep Work", duration: 45 * 60, color: Color.purple)
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 133 B

Before After
Before After

112
ReadMe.md
View file

@ -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.
@ -67,6 +96,16 @@ The application provides a comprehensive interface for monitoring system perform
- **Profile-driven onboarding** that preconfigures the entire experience based on user selection from Developer, Designer, Light Use, or Student profiles, with multi-select support for combined feature sets.
- **Keyboard shortcut remapping** allowing users to customise hotkeys for toggling the notch, opening panels, activating the colour picker, starting timers, and launching utility tools.
### Circular widgets (lock screen)
- **Circular / Gauge styles**: Lock screen widgets now include a dedicated circular mode that uses full circular gauges for charging, Bluetooth battery, AQI, and temperature. Gauges rely on the system accessory styles to match platform appearance and respect the monochrome tint preference.
- **Inline vs circular**: Inline mode keeps icon + text on a single baseline (no gauges). Circular mode increases widget sizing to match iOS-like reference visuals and adds top padding so the widget sits visually aligned with the lock screen clock.
- **AQI & provider handling**: AQI is shown only when the Open Meteo provider is active. A provider toggle in Settings selects between Open Meteo and wttr; AQI is disabled for wttr.
- **Battery & charging behaviour**: Charging state hides the standalone battery gauge and shows charging-specific accessory circular capacity. Bluetooth circular badge displays only the icon with a surrounding progress ring; battery percentages display actual values (e.g., 100%) and adopt system colouring when appropriate.
<p align="center">
<img src=".github/assets/CircularLockScreenWidgets.png" alt="Circular lock screen widgets" width="640">
</p>
## Requirements
- **macOS Sonoma 14.0** or later, optimised for macOS Sequoia 15.0 and subsequent releases.
- **MacBook with notch** including 14-inch and 16-inch MacBook Pro models across M1 Pro, M1 Max, M2 Pro, M2 Max, M3 Pro, M3 Max, M4 Pro, and M4 Max configurations.
@ -130,6 +169,16 @@ The application provides a comprehensive interface for monitoring system perform
- Timers continue running as live activities even when the notch is closed, with notifications at completion.
- Manage multiple concurrent timers and view remaining time for all active timers at a glance.
- Timer controls live in a right-side popover alongside other notch icons and are also available from the menu bar. Timers run as live activities when the notch is closed: the timer icon remains neutral when idle and adopts the configured tint while running.
- Progress and display options: choose circular ring or linear bar progress, toggle countdown text on/off, and allow the ring size to adapt to title width to avoid overlap with the notch.
- Settings: timer tint (gradient vs solid), progress style (circular or bar), and countdown visibility.
<p align="center">
<img src=".github/assets/timer.gif" alt="Timer controls and live activity" width="640">
</p>
### Customisation
- Open Settings from the menu bar icon to adjust features, appearance, behaviour, keyboard shortcuts, and privacy permissions.
- Switch between user profiles to quickly reconfigure the entire interface for different workflows.
@ -265,57 +314,6 @@ This operation clears UserDefaults, removes application support files and caches
- Search Console.app for process name "DynamicIsland" to filter relevant log entries.
- Report persistent issues with Console logs attached at the project's GitHub Issues page.
## Contributing
Contributions are welcome from developers, designers, and documentation writers. Follow these guidelines to ensure smooth collaboration:
### For developers
1. **Fork the repository** and clone your fork locally:
```bash
git clone https://github.com/your-username/Atoll.git
cd Atoll
```
2. **Create a feature branch** from main with a descriptive name:
```bash
git switch -c feature/your-feature-name
```
3. **Follow existing architecture**: Maintain the SwiftUI structure, leverage Combine for reactive data flow, and avoid introducing unnecessary dependencies.
4. **Code style**: Follow Swift API Design Guidelines, use meaningful variable names, and keep functions focused on single responsibilities.
5. **Testing**: Add unit tests for new functionality where practical, particularly for data models and utility functions.
6. **Documentation**: Update inline documentation for public APIs and add explanatory comments for complex logic.
7. **Commit changes** with clear, concise commit messages:
```bash
git add .
git commit -m "Add feature: brief description of changes"
```
8. **Push to your fork** and submit a pull request:
```bash
git push origin feature/your-feature-name
```
### Pull request guidelines
- Provide a clear description of the changes, motivation, and any related issues.
- Include testing notes explaining how to verify the changes.
- Attach screenshots or screen recordings for UI changes.
- Ensure the code builds without errors or warnings.
- Respond to review feedback promptly and update the PR as needed.
### For designers
- Contribute UI/UX improvements, mockups, and design refinements.
- Submit icon designs, animation concepts, or visual asset enhancements.
- Provide design rationale and maintain consistency with macOS Human Interface Guidelines.
### For documentation
- Improve user guides, API documentation, and troubleshooting sections.
- Translate documentation into additional languages.
- Clarify setup instructions and add missing usage examples.
### Code review process
- All pull requests require review from project maintainers before merging.
- Automated testing must pass via continuous integration workflows.
- Changes should not significantly decrease test coverage without justification.
- Breaking changes require major version updates following semantic versioning.
## License
Atoll is released under the GPL v3 License. Refer to [LICENSE](LICENSE) for the full terms.
@ -329,7 +327,15 @@ Atoll builds upon the work of several open-source projects and draws inspiration
- [**Stats**](https://github.com/exelban/stats) - source implementation for CPU temperature monitoring via SMC (System Management Controller) access, frequency sampling through IOReport bindings, and per-core CPU utilisation tracking. The system metrics collection architecture derives from Stats project readers.
## Contributors
<a href="https://github.com/Ebullioscopic/Atoll/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Ebullioscopic/Atoll" />
</a>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Ebullioscopic/Atoll&type=timeline&legend=top-left)](https://www.star-history.com/#Ebullioscopic/Atoll&type=timeline&legend=top-left)
## Updating Existing Clones
If you previously cloned DynamicIsland, update the remote to track the Atoll repository:
@ -337,3 +343,5 @@ If you previously cloned DynamicIsland, update the remote to track the Atoll rep
```bash
git remote set-url origin https://github.com/Ebullioscopic/Atoll.git
```
A heartfelt thanks to [TheBoredTeam](https://github.com/TheBoredTeam) for being supportive and being totally awesome, Atoll would not have been possible without Boring.Notch