diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..958f79d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.gif filter=lfs diff=lfs merge=lfs -text
+*.mov filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/assets/CircularLockScreenWidgets.png b/.github/assets/CircularLockScreenWidgets.png
new file mode 100644
index 0000000..c46613d
Binary files /dev/null and b/.github/assets/CircularLockScreenWidgets.png differ
diff --git a/.github/assets/atoll-demo.gif b/.github/assets/atoll-demo.gif
index 9ed8021..7e13136 100644
Binary files a/.github/assets/atoll-demo.gif and b/.github/assets/atoll-demo.gif differ
diff --git a/.github/assets/lockscreen.gif b/.github/assets/lockscreen.gif
new file mode 100644
index 0000000..e735751
--- /dev/null
+++ b/.github/assets/lockscreen.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220
+size 90875738
diff --git a/.github/assets/timer.gif b/.github/assets/timer.gif
new file mode 100644
index 0000000..529f31a
--- /dev/null
+++ b/.github/assets/timer.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9bec19433c1bddad8bc9b86b2f61f9324a109e3469753625643531d6bbb3598f
+size 1189001
diff --git a/.github/assets/timer.mov b/.github/assets/timer.mov
new file mode 100644
index 0000000..bb361ff
--- /dev/null
+++ b/.github/assets/timer.mov
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cdbf9a492037d5a53d3cd036f7030373a67ead87793139246290cf69c4b46777
+size 2128950
diff --git a/.gitignore b/.gitignore
index f7ae4f2..6b4f8f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -152,3 +152,5 @@ borrowing/
MoveToSky.xcodeproj
/MoveToSky/
mew-notch/
+
+/References/
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..4440578
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..05e6feb
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,97 @@
+
+
+
+
+# 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!
diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj
index e6c6510..cdd49d3 100644
--- a/DynamicIsland.xcodeproj/project.pbxproj
+++ b/DynamicIsland.xcodeproj/project.pbxproj
@@ -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 = "";
@@ -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";
diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 397f9e1..b8ee36b 100644
--- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -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",
diff --git a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
index 80849ad..4a6513a 100644
--- a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
+++ b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
@@ -16,7 +16,7 @@
@@ -45,7 +45,7 @@
@@ -62,7 +62,7 @@
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1) 1.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1) 1.png
new file mode 100644
index 0000000..3cd3539
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1) 1.png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1).png
new file mode 100644
index 0000000..3cd3539
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (1).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2) 1.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2) 1.png
new file mode 100644
index 0000000..bc0d91f
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2) 1.png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2).png
new file mode 100644
index 0000000..bc0d91f
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (2).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (3).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (3).png
new file mode 100644
index 0000000..b5d2a7e
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (3).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (4).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (4).png
new file mode 100644
index 0000000..5b365dc
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (4).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5) 1.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5) 1.png
new file mode 100644
index 0000000..569ebed
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5) 1.png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5).png
new file mode 100644
index 0000000..569ebed
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (5).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (6).png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (6).png
new file mode 100644
index 0000000..dbf4bc7
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024 (6).png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024.png
new file mode 100644
index 0000000..e13b268
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024.png differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json
index d76d730..dfb1568 100644
--- a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -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"
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png
deleted file mode 100644
index f114f25..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 10.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png
deleted file mode 100644
index 770e71c..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-2.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-2.png
deleted file mode 100644
index 19b6b35..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-2.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3 1.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3 1.png
deleted file mode 100644
index 19a5b32..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3 1.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3.png
deleted file mode 100644
index 19a5b32..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-3.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-4.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-4.png
deleted file mode 100644
index b723154..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-4.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-5.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-5.png
deleted file mode 100644
index bf9527b..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-5.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6 1.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6 1.png
deleted file mode 100644
index c8cef9f..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6 1.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6.png
deleted file mode 100644
index c8cef9f..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-6.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-7.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-7.png
deleted file mode 100644
index 1792a07..0000000
Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/upscaled-7.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/Atoll.png b/DynamicIsland/Assets.xcassets/logo.imageset/Atoll.png
new file mode 100644
index 0000000..e89683e
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo.imageset/Atoll.png differ
diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/ChatGPT Image Aug 13, 2025, 11_50_32 PM.png b/DynamicIsland/Assets.xcassets/logo.imageset/ChatGPT Image Aug 13, 2025, 11_50_32 PM.png
deleted file mode 100644
index 47ec851..0000000
Binary files a/DynamicIsland/Assets.xcassets/logo.imageset/ChatGPT Image Aug 13, 2025, 11_50_32 PM.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json
index eaea372..67d91ae 100644
--- a/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json
+++ b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "ChatGPT Image Aug 13, 2025, 11_50_32 PM.png",
+ "filename" : "Atoll.png",
"idiom" : "universal"
}
],
diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/Atoll_1024.png b/DynamicIsland/Assets.xcassets/logo2.imageset/Atoll_1024.png
new file mode 100644
index 0000000..e13b268
Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo2.imageset/Atoll_1024.png differ
diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png b/DynamicIsland/Assets.xcassets/logo2.imageset/ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png
deleted file mode 100644
index d2f9faf..0000000
Binary files a/DynamicIsland/Assets.xcassets/logo2.imageset/ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png and /dev/null differ
diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json
index 03b1db4..181d5eb 100644
--- a/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json
+++ b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png",
+ "filename" : "Atoll_1024.png",
"idiom" : "universal"
}
],
diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift
index e5c6517..b5dc5ee 100644
--- a/DynamicIsland/ContentView.swift
+++ b/DynamicIsland/ContentView.swift
@@ -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
diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift
index 384401b..c5147c6 100644
--- a/DynamicIsland/DynamicIslandApp.swift
+++ b/DynamicIsland/DynamicIslandApp.swift
@@ -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()
@@ -404,6 +411,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
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
@@ -477,12 +488,10 @@ 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(
status: !self.coordinator.sneakPeek.show,
type: .music,
@@ -490,14 +499,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
}
- KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
+ _ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
-
+
let mouseLocation = NSEvent.mouseLocation
-
+
var viewModel = self.vm
if Defaults[.showOnAllDisplays] {
@@ -510,131 +517,91 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}
-
+
self.closeNotchWorkItem?.cancel()
self.closeNotchWorkItem = nil
-
+
switch viewModel.notchState {
case .closed:
viewModel.open()
-
+
let workItem = DispatchWorkItem { [weak viewModel] in
viewModel?.close()
}
self.closeNotchWorkItem = workItem
-
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
case .open:
viewModel.close()
}
}
-
- 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 }
-
- // Only execute if shortcuts are enabled
+
+ KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
+
+ _ = 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()
}
diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist
index 034e9ab..bd401bd 100644
--- a/DynamicIsland/Info.plist
+++ b/DynamicIsland/Info.plist
@@ -7,14 +7,6 @@
NSAllowsArbitraryLoads
- LSUIElement
-
- NSBluetoothAlwaysUsageDescription
- Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.
- NSLocationUsageDescription
- Dynamic Island uses your location to display accurate lock screen weather.
- NSLocationWhenInUseUsageDescription
- Dynamic Island uses your location to display accurate lock screen weather.
NSScreenCaptureUsageDescription
Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.
SUEnableDownloaderService
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index a9f0a7d..2210001 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -41,7 +41,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : ""
}
},
@@ -141,7 +141,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : " – %lld"
}
},
@@ -201,13 +201,33 @@
}
}
},
+ " %@" : {
+ "comment" : "A secondary line of text displayed in a lock screen weather view, below the main weather information. The first argument is the main weather information. The second argument is an optional secondary piece of information to display below the main",
+ "isCommentAutoGenerated" : true
+ },
"-%@" : {
"comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "-%@"
+ }
+ }
+ }
},
"—" : {
"comment" : "A placeholder text used when GPU usage is not available.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "—"
+ }
+ }
+ }
},
"'Now Playing' was the only option on previous versions and works with all media apps." : {
"localizations" : {
@@ -250,7 +270,7 @@
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "“Lecture en cours” était la seule option dans les versions précédentes et fonctionne avec toutes les applications multimédias."
+ "value" : "“À l’écoute” était la seule option dans les précédentes versions et fonctionne avec toutes les applications multimédias."
}
},
"hu" : {
@@ -349,7 +369,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "(%@)"
}
},
@@ -549,7 +569,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "%.1fs"
}
},
@@ -649,7 +669,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "%@"
}
},
@@ -710,7 +730,18 @@
}
},
"%@ Models" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ modèles"
+ }
+ }
+ }
+ },
+ "%@°" : {
+ "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
+ "isCommentAutoGenerated" : true
},
"%d%%" : {
"localizations" : {
@@ -813,14 +844,29 @@
}
},
"%lld" : {
-
+ "shouldTranslate" : false
},
"%lld animations" : {
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld animations"
+ }
+ }
+ }
},
"%lld colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld couleurs"
+ }
+ }
+ }
},
"%lld%%" : {
"localizations" : {
@@ -923,11 +969,26 @@
}
},
"%lldx" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld fois"
+ }
+ }
+ }
},
"•" : {
"comment" : "A period used to separate two related items.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "•"
+ }
+ }
+ }
},
"• Bug fixes" : {
"localizations" : {
@@ -1070,7 +1131,7 @@
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Amélioration des performances"
+ "value" : "• Amélioration des performances"
}
},
"hu" : {
@@ -1230,52 +1291,165 @@
}
},
"• Thinking" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "• Réflexion"
+ }
+ }
+ }
},
"• Updated %@" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "• Mis à jour %@"
+ }
+ }
+ }
},
"••••••••" : {
-
+ "shouldTranslate" : false
},
"+%lld more" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "+%lld en plus"
+ }
+ }
+ }
+ },
+ "0" : {
+ "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
+ "isCommentAutoGenerated" : true
},
"1x" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "1x"
+ }
+ }
+ }
},
"3 items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "3 éléments"
+ }
+ }
+ }
},
"5 colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "5 couleurs"
+ }
+ }
+ }
},
"5 items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "5 éléments"
+ }
+ }
+ }
},
"7 items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "7 éléments"
+ }
+ }
+ }
},
"10 colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "10 couleurs"
+ }
+ }
+ }
},
"10 items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "10 éléments"
+ }
+ }
+ }
},
"15 colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "15 couleurs"
+ }
+ }
+ }
},
"20" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "20"
+ }
+ }
+ }
},
"20 colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "20 couleurs"
+ }
+ }
+ }
},
"30" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "30"
+ }
+ }
+ }
+ },
+ "100" : {
+ "comment" : "The text displayed below a 100% battery level in the lock screen.",
+ "isCommentAutoGenerated" : true
},
"100%" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "100%"
+ }
+ }
+ }
},
"About" : {
"localizations" : {
@@ -1317,7 +1491,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "À propos"
}
},
@@ -1419,7 +1593,7 @@
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Couleur d'accent"
+ "value" : "Couleur d'accentuation"
}
},
"hu" : {
@@ -1478,6 +1652,10 @@
}
}
},
+ "Accent colour" : {
+ "comment" : "A label describing the colour picker for the timer preset.",
+ "isCommentAutoGenerated" : true
+ },
"Access Denied" : {
"localizations" : {
"ar" : {
@@ -1518,8 +1696,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Accès Refusé"
+ "state" : "translated",
+ "value" : "Accès non autorisé"
}
},
"hu" : {
@@ -1579,23 +1757,66 @@
}
},
"Actions" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actions"
+ }
+ }
+ }
},
"Activate" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activer"
+ }
+ }
+ }
},
"Activate your license" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activer votre license"
+ }
+ }
+ }
},
"Activation" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activation"
+ }
+ }
+ }
},
"Active" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activité"
+ }
+ }
+ }
},
"Active - No Recording" : {
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actif - Aucun enregistrement"
+ }
+ }
+ }
},
"Add" : {
"localizations" : {
@@ -1637,7 +1858,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Ajouter"
}
},
@@ -1699,14 +1920,37 @@
},
"Add Animation from URL" : {
"comment" : "A title for the sheet that allows users to add animations from a URL.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter une animation depuis une URL"
+ }
+ }
+ }
},
"Add files" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter des fichiers"
+ }
+ }
+ }
},
"Add from URL..." : {
"comment" : "A button label that prompts the user to add a Lottie animation from a URL.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter depuis une URL…"
+ }
+ }
+ }
},
"Add manually" : {
"localizations" : {
@@ -1748,7 +1992,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Ajouter manuellement"
}
},
@@ -1810,7 +2054,15 @@
},
"Add New" : {
"comment" : "A label displayed below the button to add a new idle animation.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter"
+ }
+ }
+ }
},
"Add new visualizer" : {
"localizations" : {
@@ -1852,7 +2104,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Ajouter un nouveau visualiseur"
}
},
@@ -1912,6 +2164,10 @@
}
}
},
+ "Add Preset" : {
+ "comment" : "A button label that adds a new timer preset.",
+ "isCommentAutoGenerated" : true
+ },
"Additional features" : {
"localizations" : {
"ar" : {
@@ -1952,7 +2208,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Fonctionnalités supplémentaires"
}
},
@@ -2013,18 +2269,67 @@
}
},
"AI Assistant" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Assistant IA"
+ }
+ }
+ }
},
"AI is thinking..." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’IA rélfléchis"
+ }
+ }
+ }
},
"AI Model Selection" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sélection du modèle d’IA"
+ }
+ }
+ }
},
"AI Provider" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fournisseur des modèles"
+ }
+ }
+ }
},
"AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant." : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un assistant IA qui peut analyser des fichiers, des images, et fournir une aide conversationnelle. Utiliser Cmd+Maj+A pour y accéder facilement."
+ }
+ }
+ }
+ },
+ "Air quality index %d, %@" : {
+ "comment" : "Air quality accessibility label",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Air quality index %1$d, %2$@"
+ }
+ }
+ }
+ },
+ "Air quality requires the Open Meteo provider." : {
},
"AirDrop" : {
@@ -2067,7 +2372,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "AirDrop"
}
},
@@ -2167,7 +2472,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Service AirDrop indisponible"
}
},
@@ -2267,7 +2572,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Toute la journée"
}
},
@@ -2367,7 +2672,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Autoriser l'accès"
}
},
@@ -2467,7 +2772,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Toujours cacher en plein écran"
}
},
@@ -2567,7 +2872,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Toujours afficher les onglets"
}
},
@@ -2628,17 +2933,46 @@
}
},
"Animation" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Animation"
+ }
+ }
+ }
},
"Animation Name" : {
"comment" : "A label for the name input field in the URL import sheet.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de l'animation"
+ }
+ }
+ }
},
"API Configuration" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuration de l'API"
+ }
+ }
+ }
},
"API Key Required" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clé d’API nécessaire"
+ }
+ }
+ }
},
"App icon" : {
"localizations" : {
@@ -2680,7 +3014,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Icône d'application"
}
},
@@ -2780,7 +3114,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Apparence"
}
},
@@ -2840,18 +3174,50 @@
}
}
},
- "Are you sure you want to delete \"%@\"?" : {
- "comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
+ "AQI · %@" : {
+ "comment" : "A label displaying the air quality index and its corresponding description. The argument is the air quality index.",
"isCommentAutoGenerated" : true
},
+ "Are you sure you want to delete \"%@\"?" : {
+ "comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voulez-vous vraiment supprimer \"%@\" ?"
+ }
+ }
+ }
+ },
"Are you sure you want to delete this color from your history?" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voulez-vous vraiment supprimer cette couleur de votre historique ?"
+ }
+ }
+ }
},
"Ask me anything..." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demandez-moi ce que vous voulez…"
+ }
+ }
+ }
},
"Attached Files" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fichiers joints"
+ }
+ }
+ }
},
"Automatically check for updates" : {
"localizations" : {
@@ -2893,8 +3259,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Vérifier les mises à jour automatiquement"
+ "state" : "translated",
+ "value" : "Vérifier automatiquement les mises à jour"
}
},
"hu" : {
@@ -2993,8 +3359,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Télécharger les mises à jour automatiquement"
+ "state" : "translated",
+ "value" : "Télécharger automatiquement les mises à jour"
}
},
"hu" : {
@@ -3055,7 +3421,15 @@
},
"Averages" : {
"comment" : "A label displayed above the average usage and last update information for a GPU.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Moyennes"
+ }
+ }
+ }
},
"Battery" : {
"localizations" : {
@@ -3097,7 +3471,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Batterie"
}
},
@@ -3157,6 +3531,18 @@
}
}
},
+ "Battery at %d percent" : {
+ "comment" : "Battery percentage"
+ },
+ "Battery charging" : {
+ "comment" : "Battery charging without estimate"
+ },
+ "Battery charging, %@ remaining" : {
+ "comment" : "Charging time remaining"
+ },
+ "Battery fully charged" : {
+ "comment" : "Battery is full"
+ },
"Battery Information" : {
"localizations" : {
"ar" : {
@@ -3197,7 +3583,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Informations de la batterie"
}
},
@@ -3297,7 +3683,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Paramètres de la batterie"
}
},
@@ -3397,7 +3783,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Statut de la batterie"
}
},
@@ -3458,11 +3844,37 @@
}
},
"Blue dashed box shows actual size in Dynamic Island" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La boîte bleue en pointillés affiche la taille actuelle dans la Dynamic Island"
+ }
+ }
+ }
},
"Bluetooth Audio Devices" : {
"comment" : "A section header that lists Bluetooth audio devices.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareils audios Bluetooth"
+ }
+ }
+ }
+ },
+ "Bluetooth device %@ at %d percent" : {
+ "comment" : "Bluetooth device battery",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Bluetooth device %1$@ at %2$d percent"
+ }
+ }
+ }
},
"Boost your productivity with Clipboard Manager" : {
"localizations" : {
@@ -3504,7 +3916,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Boostez votre productivité avec le gestionnaire de presse-papiers"
}
},
@@ -3571,6 +3983,12 @@
"state" : "translated",
"value" : "Both"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les deux"
+ }
}
}
},
@@ -3579,23 +3997,67 @@
},
"Breakdown" : {
"comment" : "A label displayed above the breakdown of memory usage.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Répartition"
+ }
+ }
+ }
},
"Brightness HUD" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Luminosité de l’ATH"
+ }
+ }
+ }
},
"Built-in" : {
"comment" : "A label indicating that an animation is a built-in one.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Intégré"
+ }
+ }
+ }
},
"Built-in System Monitoring" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Système Intégré de Suivi"
+ }
+ }
+ }
},
"Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le système intégré de suivi détecte les changements de volume, luminosité et éclairage du clavier sans l’aide d’applications externes."
+ }
+ }
+ }
},
"Buy us a coffee" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Achetez-nous un café"
+ }
+ }
+ }
},
"Calendar" : {
"localizations" : {
@@ -3637,7 +4099,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Calendrier"
}
},
@@ -3737,8 +4199,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "L'accès au calendrier est refusé. Veuillez l'activer dans les réglages."
+ "state" : "translated",
+ "value" : "L'accès au calendrier est refusé. Veuillez l’autoriser dans les réglages."
}
},
"hu" : {
@@ -3798,17 +4260,46 @@
}
},
"Calendars" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Calendriers"
+ }
+ }
+ }
},
"Camera" : {
"comment" : "A label for the camera privacy indicator.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caméra"
+ }
+ }
+ }
},
"Camera Active" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caméra Active"
+ }
+ }
+ }
},
"Camera Status" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Status de la Caméra"
+ }
+ }
+ }
},
"Cancel" : {
"localizations" : {
@@ -3850,7 +4341,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Annuler"
}
},
@@ -3911,12 +4402,27 @@
}
},
"Center" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Centrer"
+ }
+ }
+ }
},
"Change" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Changer"
+ }
+ }
+ }
},
"Charging" : {
+ "comment" : "Charging fallback label when no estimate is available",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -3956,7 +4462,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "En charge"
}
},
@@ -4056,8 +4562,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Charge en pause : mode bureau"
+ "state" : "translated",
+ "value" : "Charge suspendue : mode bureau"
}
},
"hu" : {
@@ -4156,7 +4662,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Vérifier les mises à jour…"
}
},
@@ -4217,7 +4723,15 @@
}
},
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez un son personnalisé lorsque le minuteur . Formats supportés : MP3, M4A, WAV, AIFF."
+ }
+ }
+ }
},
"Choose a Music Source" : {
"localizations" : {
@@ -4259,7 +4773,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Choisir une source de musique"
}
},
@@ -4320,21 +4834,58 @@
}
},
"Choose AI model" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir le modèle d’IA"
+ }
+ }
+ }
},
"Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations." : {
"comment" : "A footer label under the section that lists their idle animations.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir une animation à afficher lorsque la Dynamic Island est inactive. Touchez pour sélectionner, maintenez pour supprimer."
+ }
+ }
+ }
},
"Choose File" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir un fichier"
+ }
+ }
+ }
},
"Choose your preferred AI model and configuration" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez votre modèle d’IA préféré et sa configuration"
+ }
+ }
+ }
},
"Choose Your Profile" : {
"comment" : "A title displayed above the list of profile selection options.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez votre profil"
+ }
+ }
+ }
},
"Circle" : {
"localizations" : {
@@ -4376,7 +4927,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Cercle"
}
},
@@ -4437,53 +4988,166 @@
}
},
"Clear" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer"
+ }
+ }
+ }
},
"Clear All Files" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer tous les fichiers"
+ }
+ }
+ }
},
"Clear all files removes all attached files and audio recordings. This action is permanent." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer tous les fichiers supprime tous les fichiers et enregistrements joints. Cette action est définitive."
+ }
+ }
+ }
},
"Clear Clipboard History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer l’Historique du Presse-papiers"
+ }
+ }
+ }
},
"Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer l’historique du presse-papier supprime les copies récentes. Effacer les éléments épinglés supprime vos favoris. Les deux actions sont permanentes."
+ }
+ }
+ }
},
"Clear Color History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer l’Historique de Couleurs"
+ }
+ }
+ }
},
"Clear color history removes all picked colors. Start color picking begins screen color capture mode." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer l’historique de couleurs supprime toutes les couleurs prélevées."
+ }
+ }
+ }
},
"Clear Data" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer les données"
+ }
+ }
+ }
},
"Clear Pinned Items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer les Éléments épinglés"
+ }
+ }
+ }
},
"Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cliquez sur \"Pick Color\" ou pressez simultanément Cmd+Maj+P pour prélever des couleurs"
+ }
+ }
+ }
},
"Click for details" : {
"comment" : "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cliquez pour en savoir plus"
+ }
+ }
+ }
},
"Clipboard" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Presse-papiers"
+ }
+ }
+ }
},
"Clipboard feature is disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La fonctionnalité de presse-papiers est désactivée"
+ }
+ }
+ }
},
"Clipboard History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique du presse-papiers"
+ }
+ }
+ }
},
"Clipboard History:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique du presse-papiers:"
+ }
+ }
+ }
},
"Clipboard Manager" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gestionnaire de presse-papiers"
+ }
+ }
+ }
},
"Close" : {
"localizations" : {
@@ -4525,7 +5189,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Fermer"
}
},
@@ -4586,38 +5250,102 @@
}
},
"Close assistant" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fermer l’assistant"
+ }
+ }
+ }
},
"Color Details" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Détails des couleurs"
+ }
+ }
+ }
},
"Color Formats" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Formats de couleurs"
+ }
+ }
+ }
},
"Color History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique des couleurs"
+ }
+ }
+ }
},
"Color Picked!" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Couleur prélevée!"
+ }
+ }
+ }
},
"Color Picker" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pipette de couleurs"
+ }
+ }
+ }
},
"Color Picker feature is disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La fonctionnalité de pipette est désactivée"
+ }
+ }
+ }
},
"Color Picker Panel:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Panneau de la pipette de couleurs"
+ }
+ }
+ }
},
- "Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature." : {
-
+ "Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options." : {
+ "comment" : "An explanatory text that appears when the user tries to enable color-coded color gradients in Segmented progress bars. It instructs the user to switch to Hierarchical or Gradient progress bars to enable these features.",
+ "isCommentAutoGenerated" : true
},
"Color-Coded Progress Bars" : {
"comment" : "A section header that lists the options for color-coded progress bars.",
"isCommentAutoGenerated" : true
},
"ColorPicker Disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La pipette de couleur est désactivée"
+ }
+ }
+ }
},
"Coming soon" : {
"localizations" : {
@@ -4659,7 +5387,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Bientôt disponible"
}
},
@@ -4720,10 +5448,32 @@
}
},
"Configuration" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuration"
+ }
+ }
+ }
+ },
+ "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : {
+ },
+ "Configure presets in Settings" : {
+ "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
+ "isCommentAutoGenerated" : true
},
"Connected" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connecté"
+ }
+ }
+ }
},
"Continue" : {
"localizations" : {
@@ -4765,7 +5515,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Continuer"
}
},
@@ -4825,40 +5575,128 @@
}
}
},
+ "Control timer availability and whether progress appears as a live activity while the notch is closed." : {
+ "comment" : "A footer description for the timer feature settings.",
+ "isCommentAutoGenerated" : true
+ },
"Controls" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contrôles"
+ }
+ }
+ }
},
"Copy Latest Crash Report" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier le dernier rapport de crash"
+ }
+ }
+ }
},
"Copy something to get started" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copiez quelque-chose pour commencer"
+ }
+ }
+ }
},
"Copy something to start" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier quelque-chose pour commencer"
+ }
+ }
+ }
},
"Core %lld" : {
"comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cœur %lld"
+ }
+ }
+ }
},
"CPU Usage" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation CPU"
+ }
+ }
+ }
},
"Current custom timer:" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuteur personnalisé actuel :"
+ }
+ }
+ }
+ },
+ "Current default:" : {
+ "comment" : "A label displayed alongside the currently set default timer duration.",
+ "isCommentAutoGenerated" : true
},
"Current History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique actuel :"
+ }
+ }
+ }
},
"Current Items" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Éléments actuels"
+ }
+ }
+ }
},
"Currently active" : {
"comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activée"
+ }
+ }
+ }
},
"Custom" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Personnalisé"
+ }
+ }
+ }
},
"Custom color" : {
"extractionState" : "stale",
@@ -4868,6 +5706,12 @@
"state" : "translated",
"value" : "Custom color"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Couleur personnalisée"
+ }
}
}
},
@@ -4911,8 +5755,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Personnaliser la hauteur"
+ "state" : "translated",
+ "value" : "Hauteur personnalisée"
}
},
"hu" : {
@@ -5011,8 +5855,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Personnaliser l'animation d'activité musicale"
+ "state" : "translated",
+ "value" : "Animation d'activité musicale personnalisée"
}
},
"hu" : {
@@ -5111,8 +5955,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Personnaliser la taille de l'encoche - %.0f"
+ "state" : "translated",
+ "value" : "Taille de l'encoche personnalisée - %.0f"
}
},
"hu" : {
@@ -5172,10 +6016,25 @@
}
},
"Custom Timer" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuteur personnalisé"
+ }
+ }
+ }
},
"Custom Timer Duration" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durée du minuteur personnalisée"
+ }
+ }
+ }
},
"Custom vizualizers (Lottie)" : {
"localizations" : {
@@ -5217,7 +6076,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Visualiseurs personnalisés (Lottie)"
}
},
@@ -5278,10 +6137,24 @@
}
},
"Custom: %@" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Personnalisé: %@"
+ }
+ }
+ }
},
"Customize Animation" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Personnaliser l’animation"
+ }
+ }
+ }
},
"Customize in Settings" : {
"localizations" : {
@@ -5323,7 +6196,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Personnaliser dans les paramètres"
}
},
@@ -5423,7 +6296,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Par défaut"
}
},
@@ -5483,18 +6356,51 @@
}
}
},
+ "Default Custom Timer" : {
+ "comment" : "A label displayed above the section that lets users configure a custom timer.",
+ "isCommentAutoGenerated" : true
+ },
"Default: dynamic.m4a" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Par défaut : dynamique.m4a"
+ }
+ }
+ }
},
"Delete" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer"
+ }
+ }
+ }
},
"Delete Animation" : {
"comment" : "The title of an alert that appears when trying to delete a custom animation.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer l’Animation"
+ }
+ }
+ }
},
"Delete Color" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la Couleur"
+ }
+ }
+ }
},
"Description" : {
"localizations" : {
@@ -5536,7 +6442,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Description"
}
},
@@ -5598,7 +6504,15 @@
},
"Detection Status" : {
"comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statut de la détéction"
+ }
+ }
+ }
},
"Disable" : {
"localizations" : {
@@ -5640,7 +6554,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Désactiver"
}
},
@@ -5740,7 +6654,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Désactivé"
}
},
@@ -5800,25 +6714,61 @@
}
}
},
- "Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)" : {
- "comment" : "A description of the \"Discrete\" color-coded progress bar style, including the colors used for battery and volume levels.",
+ "Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
+ "comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
"isCommentAutoGenerated" : true
},
"Disk Read" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lecture du disque"
+ }
+ }
+ }
},
"Disk Write" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Écriture du disque"
+ }
+ }
+ }
},
"Display Mode" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mode d’affichage"
+ }
+ }
+ }
},
"Display Options" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Options d’affichage"
+ }
+ }
+ }
},
"Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : {
"comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Affiche une notification sur l’ATH lorsque des appareils audio Bluetooth (Écouteurs, AirPods, enceintes) se connectent, et affiche le nom et la charge de la batterie de celui-ci."
+ }
+ }
+ }
},
"Download" : {
"localizations" : {
@@ -5860,8 +6810,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Télécharger"
+ "state" : "translated",
+ "value" : "Téléchargements"
}
},
"hu" : {
@@ -5927,6 +6877,12 @@
"state" : "translated",
"value" : "Download icon style"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Télécharger le style d’icône"
+ }
}
}
},
@@ -5937,11 +6893,24 @@
"state" : "translated",
"value" : "Download indicator style"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Télécharger le style de l’indicateur"
+ }
}
}
},
"Download indicators" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Télécharger les indicateurs"
+ }
+ }
+ }
},
"Downloads" : {
"localizations" : {
@@ -5983,7 +6952,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Téléchargements"
}
},
@@ -6083,7 +7052,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Déposer les fichiers ici"
}
},
@@ -6144,13 +7113,34 @@
}
},
"Dynamic Island" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dynamic Island"
+ }
+ }
+ }
},
"dynamic.island" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dynamic.island"
+ }
+ }
+ }
},
"Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chaque graphique peut être individuellement activé ou désactivé. L’activité réseau affiche la vitesse de téléchargement et d’envoi, et l’activité du disque affiche la vitesse de lecture et d’écriture."
+ }
+ }
+ }
},
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
"localizations" : {
@@ -6192,7 +7182,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Copiez, stockez et gérez facilement votre contenu le plus utilisé. Mettez à niveau maintenant pour des fonctionnalités avancées telles que le stockage de multiples items et un accès rapide !"
}
},
@@ -6253,7 +7243,14 @@
}
},
"Edit Animation" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifier l’Animation"
+ }
+ }
+ }
},
"Edit layout" : {
"localizations" : {
@@ -6295,7 +7292,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Modifier la mise en page"
}
},
@@ -6356,17 +7353,38 @@
}
},
"Email address" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Adresse e-mail"
+ }
+ }
+ }
},
"Enable \"Show cool face animation while inactivity\" to customize animations" : {
"comment" : "A message displayed below the section header in the IdleAnimationsSettingsSection, explaining that users can customize animations by enabling this feature.",
"isCommentAutoGenerated" : true
},
"Enable Built-in System HUD" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activer le système d’ATH intégré"
+ }
+ }
+ }
},
"Enable built-in system monitoring to replace macOS HUD notifications with Dynamic Island displays." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activez le système de suivi intégré pour remplacer les notifications macOS avec celles de Dynamic Island."
+ }
+ }
+ }
},
"Enable colored spectrograms" : {
"extractionState" : "stale",
@@ -6470,7 +7488,14 @@
}
},
"Enable ColorPicker in Settings → ColorPicker" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activez la pipette de couleur dans Réglages → Pipette de couleur"
+ }
+ }
+ }
},
"Enable global keyboard shortcuts above to customize your shortcuts." : {
@@ -6620,8 +7645,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Activer le mode de remplacement HUD"
+ "state" : "translated",
+ "value" : "Activer le mode de remplacement ATH"
}
},
"hu" : {
@@ -6720,7 +7745,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Activer l'activité musicale en direct"
}
},
@@ -6779,15 +7804,19 @@
}
}
}
- },
- "Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : {
-
},
"Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : {
},
"Enable sneak peek" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activer Sneak Peek"
+ }
+ }
+ }
},
"Enable stats monitoring in Settings to view system performance data." : {
@@ -6806,7 +7835,15 @@
},
"Engine Utilization" : {
"comment" : "A label describing the utilization of the GPU's engine components.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation"
+ }
+ }
+ }
},
"Enhance your experience with HUDs" : {
"localizations" : {
@@ -6948,7 +7985,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Profitez de votre temps libre !"
}
},
@@ -7009,7 +8046,14 @@
}
},
"Enter your Gemini API Key" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre clé Gemini API"
+ }
+ }
+ }
},
"Error" : {
"localizations" : {
@@ -7051,7 +8095,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Erreur"
}
},
@@ -7118,6 +8162,12 @@
"state" : "translated",
"value" : "Exclude apps"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Exclure des applications"
+ }
}
}
},
@@ -7161,7 +8211,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Quitter"
}
},
@@ -7264,7 +8314,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Extensions"
}
},
@@ -7326,7 +8376,15 @@
},
"External" : {
"comment" : "A label indicating an external storage device.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Externe"
+ }
+ }
+ }
},
"Fill Notch" : {
@@ -7371,7 +8429,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Terminer"
}
},
@@ -7436,10 +8494,28 @@
},
"Free · %@" : {
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Libre · %@"
+ }
+ }
+ }
+ },
+ "Fully charged" : {
+ "comment" : "Charging fallback label when already charged"
},
"Gemini API Key" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clé Gemini API"
+ }
+ }
+ }
},
"General" : {
"localizations" : {
@@ -7481,7 +8557,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Général"
}
},
@@ -7581,7 +8657,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Contrôle gestuel"
}
},
@@ -7681,7 +8757,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Sensibilité de la gestuelle"
}
},
@@ -7781,8 +8857,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Commencez"
+ "state" : "translated",
+ "value" : "Commencer"
}
},
"hu" : {
@@ -7842,7 +8918,14 @@
}
},
"Get your free API key from Google AI Studio" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obtenez votre clé API gratuitement depuis Google AI Studio"
+ }
+ }
+ }
},
"GitHub" : {
"localizations" : {
@@ -7884,7 +8967,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "GitHub"
}
},
@@ -7984,7 +9067,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Compris !"
}
},
@@ -8045,7 +9128,14 @@
}
},
"GPU Usage" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation GPU"
+ }
+ }
+ }
},
"Gradient" : {
"localizations" : {
@@ -8087,7 +9177,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Dégradé"
}
},
@@ -8148,16 +9238,44 @@
}
},
"Graph Visibility" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visibilité du graphique"
+ }
+ }
+ }
},
"Height:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hauteur :"
+ }
+ }
+ }
},
"Hide" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cacher"
+ }
+ }
+ }
},
"Hide DynamicIsland Options" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cacher les options de DynamicIsland"
+ }
+ }
+ }
},
"Hide only when NowPlaying app is in fullscreen" : {
"localizations" : {
@@ -8199,8 +9317,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Masquer seulement quand lorsque l'app en train de jouer est en plein écran"
+ "state" : "translated",
+ "value" : "Masquer seulement quand lorsque l'app \"À l’écoute\" est en plein écran"
}
},
"hu" : {
@@ -8299,7 +9417,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Hiérarchique"
}
},
@@ -8360,6 +9478,7 @@
}
},
"High" : {
+ "comment" : "High battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -8399,7 +9518,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Haut"
}
},
@@ -8460,14 +9579,36 @@
}
},
"History Size" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taille de l’historique"
+ }
+ }
+ }
},
"Horizontal:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Horizontal :"
+ }
+ }
+ }
},
"https://example.com/animation.json" : {
"comment" : "A placeholder text for a Lottie JSON URL input field.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "https://exemple.com/animation.json"
+ }
+ }
+ }
},
"https://github.com/th-ch/youtube-music" : {
"localizations" : {
@@ -8509,7 +9650,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "https://github.com/th-ch/youtube-music"
}
},
@@ -8609,8 +9750,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Style de l'HUD"
+ "state" : "translated",
+ "value" : "Style de l’ATH"
}
},
"hu" : {
@@ -8709,8 +9850,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "HUDs"
+ "state" : "translated",
+ "value" : "ATH"
}
},
"hu" : {
@@ -8771,22 +9912,61 @@
},
"Idle" : {
"comment" : "A label displayed alongside the status of a GPU device.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En veille"
+ }
+ }
+ }
},
"Idle Animation Style" : {
"comment" : "The header label for the section that lets users choose the style of idle animation.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Style de l’animation de veille"
+ }
+ }
+ }
},
"Import" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importer"
+ }
+ }
+ }
},
"Import Error" : {
"comment" : "The title of an alert that appears when there is an error importing a Lottie animation.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur d’importation"
+ }
+ }
+ }
},
"Import File..." : {
"comment" : "A menu option to import a Lottie animation from a file.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importer un fichier"
+ }
+ }
+ }
},
"In progress" : {
"localizations" : {
@@ -8828,7 +10008,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "En cours"
}
},
@@ -8889,7 +10069,14 @@
}
},
"Inactive" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Inactif"
+ }
+ }
+ }
},
"Inline" : {
"localizations" : {
@@ -8999,6 +10186,12 @@
"state" : "translated",
"value" : "Install updates automatically"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installer les mises à jour automatiquement"
+ }
}
}
},
@@ -9042,7 +10235,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Extensions installées"
}
},
@@ -9104,20 +10297,57 @@
},
"IPv4 · %@" : {
"comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "IPv4 · %@"
+ }
+ }
+ }
},
"IPv6 · %@" : {
"comment" : "A label displaying an IPv6 address associated with a network interface. The text inside the parentheses is replaced with the actual IPv6 address.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "IPv6 · %@"
+ }
+ }
+ }
},
"Keyboard Backlight HUD" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ATH de l’éclairage du clavier"
+ }
+ }
+ }
},
"Keyboard shortcuts are disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les raccourcis claviers sont désactivés"
+ }
+ }
+ }
},
"Last Updated" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernière mise à jour"
+ }
+ }
+ }
},
"Later" : {
"localizations" : {
@@ -9159,7 +10389,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Plus tard"
}
},
@@ -9220,26 +10450,76 @@
}
},
"License key" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clé de license"
+ }
+ }
+ }
},
"LinkedIn" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "LinkedIn"
+ }
+ }
+ }
},
"Liquid Glass requires macOS 26 or later." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Liquid Glass nécessite macOS 26 ou plus récent."
+ }
+ }
+ }
},
"Live Monitoring" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Suivi en directe"
+ }
+ }
+ }
},
"Live Performance Data" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Données de performance en directe"
+ }
+ }
+ }
},
"Load Average" : {
"comment" : "A label displayed above the load average values.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Moyenne de la charge"
+ }
+ }
+ }
},
"Lock Screen" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Écran verrouillé"
+ }
+ }
+ }
},
"Lock screen material" : {
@@ -9290,7 +10570,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Lien URL Lottie JSON"
}
},
@@ -9351,6 +10631,7 @@
}
},
"Low" : {
+ "comment" : "Low battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -9390,7 +10671,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Bas"
}
},
@@ -9490,7 +10771,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Mode économie d'énergie"
}
},
@@ -9550,6 +10831,9 @@
}
}
},
+ "Mac battery at %d percent" : {
+ "comment" : "Mac battery gauge accessibility label"
+ },
"Made ❤️ by Ebullioscopic" : {
},
@@ -9694,8 +10978,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Atteindre la hauteur de la barre de menu"
+ "state" : "translated",
+ "value" : "Correspondre à la hauteur de la barre de menu"
}
},
"hu" : {
@@ -9794,7 +11078,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Correspondre à la vraie hauteur de l'encoche"
}
},
@@ -9894,7 +11178,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Capacité maximale : %lld%%"
}
},
@@ -9994,7 +11278,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Média"
}
},
@@ -10096,7 +11380,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Changement de média avec les gestuelles horizontales"
}
},
@@ -10196,7 +11480,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Contrôles des médias"
}
},
@@ -10296,7 +11580,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Délai d'inactivité du média"
}
},
@@ -10356,6 +11640,10 @@
}
}
},
+ "Media output" : {
+ "comment" : "Accessibility label describing the control that opens the media output selector.",
+ "isCommentAutoGenerated" : true
+ },
"Media playback live activity" : {
"localizations" : {
"ar" : {
@@ -10396,7 +11684,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Activité de lecture de média en direct"
}
},
@@ -10496,7 +11784,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Source du Média"
}
},
@@ -10557,6 +11845,7 @@
}
},
"Medium" : {
+ "comment" : "Medium battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -10596,7 +11885,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Moyen"
}
},
@@ -10657,7 +11946,14 @@
}
},
"Memory Usage" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation de la mémoire"
+ }
+ }
+ }
},
"Menubar icon" : {
"extractionState" : "stale",
@@ -10761,6 +12057,7 @@
}
},
"Mic %@" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -10800,8 +12097,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Mic %@"
+ "state" : "translated",
+ "value" : "Microphone %@"
}
},
"hu" : {
@@ -10860,18 +12157,55 @@
}
}
},
- "Microphone" : {
- "comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
+ "Mic muted" : {
+ "comment" : "A description of the state when the microphone is muted.",
"isCommentAutoGenerated" : true
},
+ "Mic unmuted" : {
+ "comment" : "A description of the state when the microphone is unmuted.",
+ "isCommentAutoGenerated" : true
+ },
+ "Microphone" : {
+ "comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Microphone"
+ }
+ }
+ }
+ },
"Microphone Active" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Microphone actif"
+ }
+ }
+ }
},
"Microphone Status" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statut du microphone"
+ }
+ }
+ }
},
"min" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "min"
+ }
+ }
+ }
},
"Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations." : {
@@ -10916,7 +12250,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Durée minimale du survol"
}
},
@@ -10977,7 +12311,15 @@
}
},
"Minutes" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ }
+ }
},
"Mirror" : {
"localizations" : {
@@ -11019,7 +12361,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Miroir"
}
},
@@ -11119,7 +12461,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Forme du miroir"
}
},
@@ -11183,10 +12525,24 @@
},
"Monitoring Status" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statut du suivi"
+ }
+ }
+ }
},
"Monitoring Stopped" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le suivi a pris fin"
+ }
+ }
+ }
},
"More" : {
"localizations" : {
@@ -11228,7 +12584,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Plus"
}
},
@@ -11288,6 +12644,14 @@
}
}
},
+ "Move Down" : {
+ "comment" : "A button label that says \"Move Down\".",
+ "isCommentAutoGenerated" : true
+ },
+ "Move Up" : {
+ "comment" : "A button label that says \"Move Up\".",
+ "isCommentAutoGenerated" : true
+ },
"Music Source" : {
"localizations" : {
"ar" : {
@@ -11328,7 +12692,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Source de la musique"
}
},
@@ -11428,8 +12792,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "en sourdine"
+ "state" : "translated",
+ "value" : "muet"
}
},
"hu" : {
@@ -11490,7 +12854,15 @@
},
"My Animation" : {
"comment" : "A default name for a new animation added via a URL.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mon Animation"
+ }
+ }
+ }
},
"Name" : {
"localizations" : {
@@ -11532,7 +12904,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Nom"
}
},
@@ -11593,7 +12965,14 @@
}
},
"Navigation" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Navigation"
+ }
+ }
+ }
},
"Need Restart" : {
"localizations" : {
@@ -11635,7 +13014,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Redémarrage nécessaire"
}
},
@@ -11696,10 +13075,24 @@
}
},
"Network Download" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Téléchargement réseau"
+ }
+ }
+ }
},
"Network Upload" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envoi réseau"
+ }
+ }
+ }
},
"Never hide" : {
"localizations" : {
@@ -11741,7 +13134,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Ne jamais masquer"
}
},
@@ -11803,16 +13196,49 @@
},
"No active interfaces detected yet." : {
"comment" : "A message displayed when there are no active network interfaces to display.",
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune interface détectée"
+ }
+ }
+ }
+ },
+ "No audio outputs available" : {
+ "comment" : "Displayed when no audio output devices can be listed in the media output selector.",
"isCommentAutoGenerated" : true
},
"No clipboard history" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’historique du presse-papiers est vide"
+ }
+ }
+ }
},
"No Colors Found" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune couleur n’a été trouvé"
+ }
+ }
+ }
},
"No colors match '%@'" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune couleur ne correspond à \"%@\""
+ }
+ }
+ }
},
"No Colors Yet" : {
@@ -11857,7 +13283,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Aucune animation personnalisée disponible"
}
},
@@ -11957,7 +13383,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Aucun visualiseur personnalisé"
}
},
@@ -12057,7 +13483,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Aucun événement aujourd'hui"
}
},
@@ -12171,7 +13597,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Aucune extension installée"
}
},
@@ -12232,21 +13658,46 @@
}
},
"No favorites" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun favoris"
+ }
+ }
+ }
},
"No Graphs Enabled" : {
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
"isCommentAutoGenerated" : true
},
+ "No presets configured. Add a preset to make it appear in the timer popover." : {
+ "comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.",
+ "isCommentAutoGenerated" : true
+ },
"No process data yet. Keep stats open for a moment." : {
"comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.",
"isCommentAutoGenerated" : true
},
"No results" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun résultats"
+ }
+ }
+ }
},
"No results found" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun résultats"
+ }
+ }
+ }
},
"No storage devices detected yet." : {
"comment" : "A message indicating that no storage devices have been detected yet.",
@@ -12292,7 +13743,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Hauteur de l'affichage hors-Notch"
}
},
@@ -12354,7 +13805,15 @@
},
"Not active" : {
"comment" : "A description of whether the camera is currently active.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non active"
+ }
+ }
+ }
},
"Not Now" : {
"localizations" : {
@@ -12396,7 +13855,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Pas pour l'instant"
}
},
@@ -12499,7 +13958,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Comportement du Notch"
}
},
@@ -12599,7 +14058,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Hauteur d'affichage du Notch"
}
},
@@ -12699,7 +14158,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Hauteur du Notch"
}
},
@@ -12799,8 +14258,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "OK"
+ "state" : "translated",
+ "value" : "Ok"
}
},
"hu" : {
@@ -12899,7 +14358,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Impossible de charger un ou plusieurs fichiers"
}
},
@@ -13005,6 +14464,14 @@
},
"Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : {
+ },
+ "Output devices" : {
+ "comment" : "Section title for the list of available audio output devices in the media output selector.",
+ "isCommentAutoGenerated" : true
+ },
+ "Output volume" : {
+ "comment" : "Label describing the system volume control inside the media output selector.",
+ "isCommentAutoGenerated" : true
},
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
@@ -13101,7 +14568,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Branché"
}
},
@@ -13171,14 +14638,48 @@
},
"Position" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Position"
+ }
+ }
+ }
+ },
+ "Preset name" : {
+ "comment" : "A label for the name of a timer preset.",
+ "isCommentAutoGenerated" : true
+ },
+ "Presets" : {
+ "comment" : "A header label for the list of timer presets.",
+ "isCommentAutoGenerated" : true
+ },
+ "Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order." : {
+ "comment" : "A footer for the \"Timer Presets\" section in the TimerSettings view, explaining how to manage and reorder timer presets.",
+ "isCommentAutoGenerated" : true
},
"Pressure" : {
"comment" : "A label describing the memory pressure status.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pression"
+ }
+ }
+ }
},
"Preview" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aperçu"
+ }
+ }
+ }
},
"Preview Zoom:" : {
@@ -13200,6 +14701,9 @@
}
}
}
+ },
+ "Progress style" : {
+
},
"Progressbar style" : {
"localizations" : {
@@ -13241,7 +14745,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Style de la barre de progression"
}
},
@@ -13302,7 +14806,14 @@
}
},
"Quick Actions" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actions rapides"
+ }
+ }
+ }
},
"Quick Presets" : {
@@ -13347,7 +14858,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Quitter"
}
},
@@ -13447,7 +14958,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Quitter l'application"
}
},
@@ -13515,31 +15026,88 @@
"state" : "translated",
"value" : "Quit boring.notch"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Quitter boring.notch"
+ }
}
}
},
"Read" : {
"comment" : "A heading displayed for the user's disk read speed.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lecture"
+ }
+ }
+ }
},
"Ready" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prêt"
+ }
+ }
+ }
},
"Reasoning Mode" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mode de raisonnemen"
+ }
+ }
+ }
},
"Recent Colors" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Couleurs récentes"
+ }
+ }
+ }
},
"Recording" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrement"
+ }
+ }
+ }
},
"Recording Detected" : {
"comment" : "A text description of a red dot displayed in the Dynamic Island header when screen recording is active.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrement détécté"
+ }
+ }
+ }
},
"Recording Status" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statut de l’enregistrement"
+ }
+ }
+ }
},
"Release name" : {
"localizations" : {
@@ -13581,7 +15149,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Nom de la version"
}
},
@@ -13681,7 +15249,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Se souvenir du dernier onglet"
}
},
@@ -13742,22 +15310,61 @@
}
},
"Reminders" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rappels"
+ }
+ }
+ }
+ },
+ "Replace repeat button with media output control" : {
+ "comment" : "A toggle that allows users to replace the repeat button with a media output control.",
+ "isCommentAutoGenerated" : true
},
"Replaces system HUD notifications with Dynamic Island displays." : {
},
"Request Access" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demander l’accès"
+ }
+ }
+ }
},
"Reset" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialiser"
+ }
+ }
+ }
},
"Reset All" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tout réinitialiser"
+ }
+ }
+ }
},
"Reset to Default" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialiser aux paramètres d’origine "
+ }
+ }
+ }
},
"Restart" : {
"localizations" : {
@@ -13799,7 +15406,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Redémarrer"
}
},
@@ -13860,13 +15467,46 @@
}
},
"Restart Dynamic Island" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Relancer Dynamic Island"
+ }
+ }
+ }
+ },
+ "Restore" : {
+ "comment" : "The text for a button that restores default settings.",
+ "isCommentAutoGenerated" : true
+ },
+ "Restore default timer presets?" : {
+ "comment" : "A confirmation dialog title asking if the user wants to restore the default timer presets.",
+ "isCommentAutoGenerated" : true
+ },
+ "Restore Defaults" : {
+ "comment" : "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.",
+ "isCommentAutoGenerated" : true
},
"Resume" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reprendre"
+ }
+ }
+ }
},
"Rotation:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rotation:"
+ }
+ }
+ }
},
"Running" : {
"localizations" : {
@@ -13908,7 +15548,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "En cours d'exécution"
}
},
@@ -13969,22 +15609,64 @@
}
},
"Save" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer"
+ }
+ }
+ }
},
"Save Changes" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer les changements"
+ }
+ }
+ }
},
"Save Configuration" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer la configuration"
+ }
+ }
+ }
},
"Scale Down" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réduire l’échelle"
+ }
+ }
+ }
},
"Scale Up 2x" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doubler l’échelle"
+ }
+ }
+ }
},
"Scale:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Échelle :"
+ }
+ }
+ }
},
"Screen Assistant" : {
@@ -13993,25 +15675,67 @@
},
"Screen Assistant:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Assistant d’écran :"
+ }
+ }
+ }
},
"Screen Recording Detection" : {
},
"Screenshot options" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Options de capture d’écran"
+ }
+ }
+ }
},
"Screenshot Type" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Format de la capture d’écran"
+ }
+ }
+ }
},
"Search clipboard..." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rechercher dans le presse-papiers…"
+ }
+ }
+ }
},
"Search colors..." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rechercher des couleurs…"
+ }
+ }
+ }
},
"Search..." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rechercher…"
+ }
+ }
+ }
},
"seconds" : {
"extractionState" : "stale",
@@ -14021,20 +15745,52 @@
"state" : "translated",
"value" : "seconds"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secondes"
+ }
}
}
},
"Seconds" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Secondes"
+ }
+ }
+ }
},
"Segmented" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Segmenté"
+ }
+ }
+ }
},
"Select a color to view details" : {
+ },
+ "Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF." : {
+ "comment" : "A description below the option to select a custom timer sound.",
+ "isCommentAutoGenerated" : true
},
"Select Calendars" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sélectionner des calendriers"
+ }
+ }
+ }
},
"Select one or more profiles to customize your experience" : {
"comment" : "A description below the header that instructs users to select one or more profiles to customize their experience.",
@@ -14080,7 +15836,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Sélectionnez la source de musique que vous souhaitez utiliser. Vous pourrez la modifier plus tard dans les paramètres de l'application."
}
},
@@ -14242,7 +15998,15 @@
},
"Selected" : {
"comment" : "A label indicating that an animation is currently selected.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sélectionnée"
+ }
+ }
+ }
},
"Selected animation" : {
"localizations" : {
@@ -14284,8 +16048,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Animations sélectionné(es)"
+ "state" : "translated",
+ "value" : "Animation sélectionnée"
}
},
"hu" : {
@@ -14345,7 +16109,14 @@
}
},
"Sensitivity" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sensibilité"
+ }
+ }
+ }
},
"Session · ↓ %@ · ↑ %@" : {
"comment" : "A row displaying the total downloaded and uploaded bytes for the network session.",
@@ -14356,14 +16127,24 @@
"state" : "new",
"value" : "Session · ↓ %1$@ · ↑ %2$@"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Session · ↓ %1$@ · ↑ %2$@"
+ }
}
}
},
"Set" : {
-
- },
- "Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Définir"
+ }
+ }
+ }
},
"Settings" : {
"localizations" : {
@@ -14405,7 +16186,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Paramètres"
}
},
@@ -14465,6 +16246,10 @@
}
}
},
+ "Settings…" : {
+ "comment" : "A command that shows the settings window.",
+ "isCommentAutoGenerated" : true
+ },
"Shelf" : {
"localizations" : {
"ar" : {
@@ -14505,8 +16290,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Étagère"
+ "state" : "translated",
+ "value" : "Tiroir"
}
},
"hu" : {
@@ -14605,7 +16390,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Raccourcis"
}
},
@@ -14774,11 +16559,24 @@
"state" : "translated",
"value" : "Show charging indicator"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher l’indicateur de charge"
+ }
}
}
},
"Show Color History" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher l’historique de couleur"
+ }
+ }
+ }
},
"Show Color Picker Panel" : {
@@ -14883,6 +16681,9 @@
}
}
}
+ },
+ "Show countdown" : {
+
},
"Show download progress" : {
"extractionState" : "stale",
@@ -14892,8 +16693,17 @@
"state" : "translated",
"value" : "Show download progress"
}
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher la vitesse de téléchargement"
+ }
}
}
+ },
+ "Show floating pause/stop controls" : {
+
},
"Show on a specific display" : {
"localizations" : {
@@ -14935,7 +16745,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Afficher sur un écran spécifique"
}
},
@@ -15035,7 +16845,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Afficher sur tous les écrans"
}
},
@@ -15094,6 +16904,9 @@
}
}
}
+ },
+ "Show progress" : {
+
},
"Show shuffle and repeat buttons" : {
"localizations" : {
@@ -15135,7 +16948,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Afficher les boutons \"aléatoire\" et \"répéter\""
}
},
@@ -15194,6 +17007,9 @@
}
}
}
+ },
+ "Show timer name" : {
+
},
"Shows a lock icon in the Dynamic Island when the screen is locked. Use the toggles above to control the lock screen media panel and weather capsule." : {
@@ -15202,10 +17018,24 @@
},
"Shows the model's reasoning process before the final answer" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher le processus de réflexion du modèle avant la réponse."
+ }
+ }
+ }
},
"Size & Scale" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taille et échelle"
+ }
+ }
+ }
},
"Slider color" : {
"localizations" : {
@@ -15307,8 +17137,8 @@
}
}
},
- "Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)" : {
- "comment" : "A detailed description of the \"Smooth\" color-coded gradient display feature.",
+ "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." : {
+ "comment" : "A description of how the color-coding feature works.",
"isCommentAutoGenerated" : true
},
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
@@ -15351,8 +17181,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Sneak Peek montre le titre du média et l'artiste sous l'encoche pendant quelques secondes."
+ "state" : "translated",
+ "value" : "Sneak Peek montre l'artiste et le titre du média sous l'encoche pendant quelques secondes."
}
},
"hu" : {
@@ -15451,7 +17281,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Style du Sneak Peek"
}
},
@@ -15555,8 +17385,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Mises à jour du logiciel"
+ "state" : "translated",
+ "value" : "Mises à jour logicielles"
}
},
"hu" : {
@@ -15614,6 +17444,9 @@
}
}
}
+ },
+ "Solid colour" : {
+
},
"Speed" : {
"localizations" : {
@@ -15655,7 +17488,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Vitesse"
}
},
@@ -15716,7 +17549,14 @@
}
},
"Speed:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vitesse:"
+ }
+ }
+ }
},
"Square" : {
"localizations" : {
@@ -15758,7 +17598,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Carré"
}
},
@@ -15819,61 +17659,177 @@
}
},
"Start" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Démarrer"
+ }
+ }
+ }
},
"Start a conversation to see your chat history here" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commencez une conversation pour voir votre historique"
+ }
+ }
+ }
},
"Start Color Picking" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commencer à "
+ }
+ }
+ }
+ },
+ "Start Custom Timer" : {
+ "comment" : "A button label that starts a custom timer.",
+ "isCommentAutoGenerated" : true
},
"Start Demo Timer:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lancer le minuteur de démo"
+ }
+ }
+ }
},
"Start Monitoring" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commencer le suivi"
+ }
+ }
+ }
},
"Start picking colors to build your history" : {
},
"Start recording" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commencer l’enregistrement"
+ }
+ }
+ }
},
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
},
"Stats" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statistiques"
+ }
+ }
+ }
},
"Stats Disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Statistiques désactivées"
+ }
+ }
+ }
},
"Stats Feature Disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La fonctionnalité de statistiques est désactivée"
+ }
+ }
+ }
},
"Stats feature is disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La fonctionnalité de statistiques est désactivée"
+ }
+ }
+ }
},
"Stats monitoring displays performance graphs in the Dynamic Island. The detailed panel (Cmd+Shift+S) shows Activity Monitor-style detailed graphs and metrics." : {
},
"Stats panel is disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le panneau des statistiques est désactivé"
+ }
+ }
+ }
},
"Stats Panel:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Panneau des statistiques"
+ }
+ }
+ }
},
"Status & Actions" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actions et statut"
+ }
+ }
+ }
},
"Stop" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Arrêter"
+ }
+ }
+ }
},
"Stop Monitoring" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre fin à le suivi"
+ }
+ }
+ }
},
"Stop recording" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre fin à l’enregisterement"
+ }
+ }
+ }
},
"Stopped" : {
"localizations" : {
@@ -15915,8 +17871,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Arrêt"
+ "state" : "translated",
+ "value" : "Arrêté"
}
},
"hu" : {
@@ -16077,19 +18033,50 @@
}
},
"Supports reasoning mode" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supporte la réfléxion"
+ }
+ }
+ }
},
"Swap" : {
"comment" : "A label for the section of the memory dashboard related to swap usage.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Espace d'échange"
+ }
+ }
+ }
},
"Swap is disabled on this system." : {
"comment" : "A message indicating that swap memory is disabled on the user's system.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’espace d’échange est désactivé sur ce système."
+ }
+ }
+ }
},
"System" : {
"comment" : "A label displayed above the system, load average, and uptime information.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Système"
+ }
+ }
+ }
},
"System features" : {
"localizations" : {
@@ -16131,8 +18118,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Fonctionnalités système"
+ "state" : "translated",
+ "value" : "Fonctionnalités du système"
}
},
"hu" : {
@@ -16192,19 +18179,50 @@
}
},
"System Performance Monitor" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Suivi des Performances Système"
+ }
+ }
+ }
},
"Take area screenshot" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prendre une capture d’écran de zone"
+ }
+ }
+ }
+ },
+ "Temperature unit" : {
},
"TheDynamicIsland" : {
+ },
+ "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." : {
+
},
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
+ },
+ "This duration powers the \"Custom\" option inside the timer popover for quick access." : {
+ "comment" : "A footer description for the custom timer settings, explaining their purpose.",
+ "isCommentAutoGenerated" : true
},
"Time Range" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Période de temps"
+ }
+ }
+ }
},
"Time to Full Charge: %lld min" : {
"localizations" : {
@@ -16246,7 +18264,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Temps avant la charge complète : %lld min"
}
},
@@ -16307,18 +18325,60 @@
}
},
"Timer" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuteur"
+ }
+ }
+ }
},
"Timer Disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minuteur désactivé"
+ }
+ }
+ }
},
"Timer Feature" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fonctionnalité du Minuteur"
+ }
+ }
+ }
},
"Timer feature is disabled" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La fonctionnalité du minuteur est désactivée"
+ }
+ }
+ }
+ },
+ "Timer Presets" : {
+ "comment" : "A section header in the timer settings view, describing timer presets.",
+ "isCommentAutoGenerated" : true
},
"Timer Sound" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Son du Minuteur"
+ }
+ }
+ }
+ },
+ "Timer tint" : {
},
"Toggle Notch Open:" : {
@@ -16361,8 +18421,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Activer/désactiver le Notch Ouvert:"
+ "state" : "translated",
+ "value" : "Activer/désactiver l’encoche :"
}
},
"hu" : {
@@ -16461,7 +18521,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Activer/désactiver Sneak Peek:"
}
},
@@ -16526,19 +18586,55 @@
},
"Total · %@" : {
"comment" : "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Total · %@"
+ }
+ }
+ }
},
"Transform" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transformez"
+ }
+ }
+ }
},
"Transform your notch truly yours" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reprenez le contrôle sur votre encoche"
+ }
+ }
+ }
},
"Try adjusting your search terms" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Essayez d’ajuster vos termes de recherche"
+ }
+ }
+ }
},
"Try different search terms" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Essayez d’autres termes de recherche"
+ }
+ }
+ }
},
"Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : {
"comment" : "A footer text explaining the gesture control functionality.",
@@ -16582,8 +18678,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Glisser deux doigts vers le haut sur l'encoche pour fermer, glisser deux doigts vers le bas sur l'encoche pour l'ouvrir lorsque l'option **Ouvrir l'encoche au survolant** est désactivée"
+ "state" : "translated",
+ "value" : "Glisser deux doigts vers le haut sur l'encoche pour la fermer, glisser deux doigts vers le bas sur l'encoche pour l'ouvrir lorsque l'option **Ouvrir l'encoche au survolant** est désactivée"
}
},
"hu" : {
@@ -16643,7 +18739,14 @@
}
},
"UI Mode" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mode UI"
+ }
+ }
+ }
},
"Uninstall" : {
"localizations" : {
@@ -16685,7 +18788,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Désinstaller"
}
},
@@ -16785,7 +18888,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Déverrouillez des fonctionnalités avancées et améliorez votre expérience. Mettez à niveau maintenant pour plus de personnalisations !"
}
},
@@ -16946,7 +19049,15 @@
}
},
"Update Timer" : {
-
+ "extractionState" : "stale",
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifier le minuteur"
+ }
+ }
+ }
},
"Upgrade to Pro" : {
"localizations" : {
@@ -16988,7 +19099,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Passer à la version Pro"
}
},
@@ -17050,15 +19161,39 @@
},
"Upload" : {
"comment" : "A label displayed above the user's upload statistics.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envois"
+ }
+ }
+ }
},
"Usage" : {
"comment" : "A label displayed above the CPU usage statistics.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation"
+ }
+ }
+ }
},
"Usage Breakdown" : {
"comment" : "A heading for the breakdown of CPU usage.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Répartition de l’utilisation"
+ }
+ }
+ }
},
"Use music visualizer spectrogram" : {
"localizations" : {
@@ -17100,7 +19235,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Utiliser le spectrogramme du visualiseur de musique"
}
},
@@ -17162,18 +19297,42 @@
},
"Used" : {
"comment" : "A label describing the percentage of memory that is currently in use.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisé"
+ }
+ }
+ }
},
"Used · %@" : {
"comment" : "A label showing the used space on a storage device, followed by the size of that space. The argument is the number of bytes used on the device.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisé · %@"
+ }
+ }
+ }
},
"Uses event-driven private API for real-time screen recording detection" : {
},
"Utilization" : {
"comment" : "A label displayed alongside the utilization percentage in the GPU detail view.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisation"
+ }
+ }
+ }
},
"Version" : {
"localizations" : {
@@ -17215,7 +19374,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Version"
}
},
@@ -17315,8 +19474,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Infos de la version"
+ "state" : "translated",
+ "value" : "Informations de la version"
}
},
"hu" : {
@@ -17376,7 +19535,14 @@
}
},
"Vertical:" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vertical :"
+ }
+ }
+ }
},
"View on GitHub: th-ch/youtube-music" : {
"localizations" : {
@@ -17418,8 +19584,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Voir sur GitHub: th-ch/youtube-music"
+ "state" : "translated",
+ "value" : "Afficher sur GitHub: th-ch/youtube-music"
}
},
"hu" : {
@@ -17479,6 +19645,16 @@
}
},
"Volume HUD" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ATH du volume"
+ }
+ }
+ }
+ },
+ "Weather data provider" : {
},
"Weather: %@ %@" : {
@@ -17543,7 +19719,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Bienvenue"
}
},
@@ -17643,7 +19819,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "Nouveautés"
}
},
@@ -17712,18 +19888,43 @@
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
},
- "Width:" : {
+ "Widget layout" : {
+ },
+ "Width:" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Largueur :"
+ }
+ }
+ }
},
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
},
"Write" : {
"comment" : "A label for the write section of the disk overview.",
- "isCommentAutoGenerated" : true
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Écriture"
+ }
+ }
+ }
},
"You" : {
-
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous"
+ }
+ }
+ }
},
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {
"localizations" : {
@@ -17765,8 +19966,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Vous pouvez maintenant profiter de l'application. Si vous voulez modifier davantage les choses, vous pouvez toujours consulter les paramètres."
+ "state" : "translated",
+ "value" : "Vous pouvez maintenant utiliser l'application. Si vous voulez modifier plus de choses, vous pouvez toujours aller dans les réglages."
}
},
"hu" : {
@@ -17865,8 +20066,8 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Tout est prêt!"
+ "state" : "translated",
+ "value" : "Tout est prêt !"
}
},
"hu" : {
@@ -17965,7 +20166,7 @@
},
"fr" : {
"stringUnit" : {
- "state" : "needs_review",
+ "state" : "translated",
"value" : "YouTube Music requiert l'installation de cette application tierce : "
}
},
diff --git a/DynamicIsland/components/ColorCodedProgressBar.swift b/DynamicIsland/components/ColorCodedProgressBar.swift
index 6698aae..8d620bc 100644
--- a/DynamicIsland/components/ColorCodedProgressBar.swift
+++ b/DynamicIsland/components/ColorCodedProgressBar.swift
@@ -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))
}
}
diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift
index 13d6c6d..671e20f 100644
--- a/DynamicIsland/components/Live activities/InlineHUD.swift
+++ b/DynamicIsland/components/Live activities/InlineHUD.swift
@@ -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,62 +160,147 @@ 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 {
switch(value) {
case 0:
diff --git a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
index 911293b..97cceab 100644
--- a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
+++ b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
@@ -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) {
diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
index b539d8b..a351e66 100644
--- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
@@ -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,29 +360,45 @@ 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
- controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
- musicManager.toggleShuffle()
- }
-
- controlButton(icon: "backward.fill", size: 18) {
- musicManager.previousTrack()
- }
-
- playPauseButton
-
- controlButton(icon: "forward.fill", size: 18) {
- musicManager.nextTrack()
- }
-
- // Always show repeat on lock screen
- controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
- musicManager.toggleRepeat()
+ 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()
+ }
+
+ controlButton(icon: "backward.fill", size: 18) {
+ musicManager.previousTrack()
+ }
+
+ playPauseButton
+
+ controlButton(icon: "forward.fill", size: 18) {
+ musicManager.nextTrack()
+ }
+
+ if showMediaOutputControl {
+ mediaOutputControlButton
+ } else {
+ controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
+ musicManager.toggleRepeat()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: alignment)
}
private var playPauseButton: some View {
@@ -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 {
diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
index 9aa2bee..8d0380d 100644
--- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
@@ -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 {
+ 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"
+ }
+ }
}
+
+
+
+
diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift
index 23ebb07..22af312 100644
--- a/DynamicIsland/components/MinimalisticMusicView.swift
+++ b/DynamicIsland/components/MinimalisticMusicView.swift
@@ -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)
diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift
index 2a9e3b4..83dc5ec 100644
--- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift
+++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift
@@ -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
+ }
+ }
}
}
diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
index a18243f..4726843 100644
--- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
+++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
@@ -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)
-
- // 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)
-
- 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)
- }
-
- Spacer()
-
- // Visualizer aligned to bottom
- if useMusicVisualizer {
- visualizer
+ 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)
+
+ 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.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)
+
+ 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,7 +230,61 @@ 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 {
case .off: return "repeat"
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index f999438..1744fb5 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -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 = []
+
+ 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
diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift
index 27db6f5..2650198 100644
--- a/DynamicIsland/components/Notch/NotchTimerView.swift
+++ b/DynamicIsland/components/Notch/NotchTimerView.swift
@@ -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())
diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift
index dc29bb0..9e9135f 100644
--- a/DynamicIsland/components/Settings/SettingsView.swift
+++ b/DynamicIsland/components/Settings/SettingsView.swift
@@ -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)
-
- if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
+ .disabled(colorCodingDisabled)
+
+ 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)
-
- HStack(spacing: 20) {
- VStack(alignment: .center, spacing: 8) {
- Text("Minutes")
- .font(.system(size: 14, weight: .medium))
- .foregroundStyle(.secondary)
-
- 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) {
+ Group {
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Default Custom Timer")
+ .font(.headline)
+
+ TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
+ TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
+ TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
+
HStack {
- Text("Current custom timer:")
+ Text("Current default:")
.foregroundStyle(.secondary)
- Text(customTimerDisplayText)
- .foregroundStyle(.primary)
+ Text(customDurationDisplay)
+ .font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
-
- HStack {
- Spacer()
- Button("Update Timer") {
- customTimerDuration = Double(customMinutes * 60 + customSeconds)
- }
- .disabled(customMinutes == 0 && customSeconds == 0)
- .buttonStyle(.borderedProminent)
+ }
+ .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.")
+ }
+
+ 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.")
}
- .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) {
+
+ 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) }
+ )
+ }
+ }
+
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)
+
+ Spacer()
+
+ 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)
+ }
}
-
- 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 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
-
- 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 movePresetUp(_ index: Int) {
+ guard index > timerPresets.startIndex else { return }
+ _ = withAnimation(.smooth) {
+ timerPresets.swapAt(index, index - 1)
+ }
+ }
+
+ 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
+
+ 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 {
+ Binding(
+ get: { components.hours },
+ set: { updateDuration(hours: $0) }
+ )
+ }
+
+ private var minutesBinding: Binding {
+ Binding(
+ get: { components.minutes },
+ set: { updateDuration(minutes: $0) }
+ )
+ }
+
+ private var secondsBinding: Binding {
+ Binding(
+ get: { components.seconds },
+ set: { updateDuration(seconds: $0) }
+ )
+ }
+
+ private var colorBinding: Binding {
+ 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
+
+ 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
diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift
index 7bb3027..a1bd64c 100644
--- a/DynamicIsland/components/Tabs/TabSelectionView.swift
+++ b/DynamicIsland/components/Tabs/TabSelectionView.swift
@@ -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))
diff --git a/DynamicIsland/components/Timer/TimerControlOverlay.swift b/DynamicIsland/components/Timer/TimerControlOverlay.swift
new file mode 100644
index 0000000..86453e6
--- /dev/null
+++ b/DynamicIsland/components/Timer/TimerControlOverlay.swift
@@ -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))
+}
diff --git a/DynamicIsland/components/Timer/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift
index cd32767..9eb6bd8 100644
--- a/DynamicIsland/components/Timer/TimerIconAnimation.swift
+++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift
@@ -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
diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift
index 9e5aff2..e9bd040 100644
--- a/DynamicIsland/components/Timer/TimerLiveActivity.swift
+++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift
@@ -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
-
- // Center - Expandable timer info (similar to music banner)
- timerInfoSection
-
- // Right side - Timer countdown text
- timerCountdownSection
+ 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)
+ }
+
+ Rectangle()
+ .fill(.black)
+ .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
+
+ 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)
-
- // Progress indicator
- if timerManager.totalDuration > 0 {
- ProgressView(value: timerManager.progress)
- .progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
- .frame(width: 30, height: 2)
+ 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)
+ }
+
+ 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)
}
}
diff --git a/DynamicIsland/components/Timer/TimerPopover.swift b/DynamicIsland/components/Timer/TimerPopover.swift
new file mode 100644
index 0000000..c5dcbfc
--- /dev/null
+++ b/DynamicIsland/components/Timer/TimerPopover.swift
@@ -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
+
+ 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)
+}
diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift
index 58736df..1ee0b07 100644
--- a/DynamicIsland/enums/generic.swift
+++ b/DynamicIsland/enums/generic.swift
@@ -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"
+ }
+ }
+}
diff --git a/DynamicIsland/managers/AudioRouteManager.swift b/DynamicIsland/managers/AudioRouteManager.swift
new file mode 100644
index 0000000..a481a2b
--- /dev/null
+++ b/DynamicIsland/managers/AudioRouteManager.swift
@@ -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.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.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.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.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.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.size),
+ &target
+ )
+ if status == noErr {
+ DispatchQueue.main.async { [weak self] in
+ self?.activeDeviceID = deviceID
+ }
+ refreshDevices()
+ }
+ }
+}
diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift
index 857d5b7..5ee4e7e 100644
--- a/DynamicIsland/managers/BluetoothAudioManager.swift
+++ b/DynamicIsland/managers/BluetoothAudioManager.swift
@@ -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()
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 = []
+ 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)")
@@ -201,9 +213,15 @@ class BluetoothAudioManager: ObservableObject {
connectedDevices.append(audioDevice)
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
-
- // Placeholder: Return nil if not available
- // TODO: Implement actual battery level reading if device supports it
+ updateBatteryStatuses()
+
+ 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
}
@@ -372,6 +410,590 @@ class BluetoothAudioManager: ObservableObject {
default: return .generic
}
}
+
+ private func refreshBatteryLevelsForConnectedDevices() {
+ updateBatteryStatuses(force: true)
+
+ var updatedDevices: [BluetoothAudioDevice] = []
+ for device in connectedDevices {
+ let refreshedLevel =
+ batteryLevelFromRegistry(forAddress: device.address)
+ ?? batteryLevelFromRegistry(forName: device.name)
+ ?? batteryLevelFromDefaults(forAddress: device.address)
+ ?? batteryLevelFromDefaults(forName: device.name)
+ ?? device.batteryLevel
+ let updatedDevice = device.withBatteryLevel(refreshedLevel)
+ updatedDevices.append(updatedDevice)
+
+ if let refreshedLevel {
+ clearMissingBatteryInfo(forName: device.name, address: device.address)
+ } else {
+ logMissingBatteryInfo(forName: device.name, address: device.address)
+ }
+ }
+
+ connectedDevices = updatedDevices
+ if let last = connectedDevices.last {
+ lastConnectedDevice = last
+ }
+ }
+
+ private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
+ guard let address, !address.isEmpty else { return nil }
+ guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
+ guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
+
+ let normalizedTarget = normalizeBluetoothIdentifier(address)
+ var bestMatch: Int?
+
+ for (key, value) in deviceCache {
+ guard let payload = value as? [String: Any] else { continue }
+ if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
+ if let level = extractBatteryPercentage(from: payload) {
+ let clamped = clampBatteryPercentage(level)
+ bestMatch = max(bestMatch ?? clamped, clamped)
+ }
+ }
+ }
+
+ return bestMatch
+ }
+
+ private func batteryLevelFromDefaults(forName name: String) -> Int? {
+ guard !name.isEmpty else { return nil }
+ guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
+ guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
+
+ var bestMatch: Int?
+
+ for value in deviceCache.values {
+ guard let payload = value as? [String: Any] else { continue }
+ let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
+ if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
+ if let level = extractBatteryPercentage(from: payload) {
+ let clamped = clampBatteryPercentage(level)
+ bestMatch = max(bestMatch ?? clamped, clamped)
+ }
+ }
+ }
+
+ return bestMatch
+ }
+
+ private func batteryLevelFromRegistry(forName name: String) -> Int? {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let normalized = normalizeProductName(trimmed)
+ guard !normalized.isEmpty else { return nil }
+ if let value = batteryStatusByName[normalized] {
+ return clampBatteryPercentage(value)
+ }
+ return nil
+ }
+
+ private func updateBatteryStatuses(force: Bool = false) {
+ let now = Date()
+ if !force, let lastBatteryStatusUpdate,
+ now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
+ return
+ }
+
+ var combinedAddressPercentages: [String: Int] = [:]
+ var combinedNamePercentages: [String: Int] = [:]
+
+ let registry = collectRegistryBatteryLevels()
+ mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
+ mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
+
+ let defaults = collectDefaultsBatteryLevels()
+ mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
+ mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
+
+ let profiler = collectSystemProfilerBatteryLevels()
+ mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
+ mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
+
+ var statuses: [String: String] = [:]
+ for (key, value) in combinedAddressPercentages {
+ statuses[key] = String(clampBatteryPercentage(value))
+ }
+
+ let applyUpdates = {
+ self.batteryStatus = statuses
+ self.batteryStatusByAddress = combinedAddressPercentages
+ self.batteryStatusByName = combinedNamePercentages
+ self.lastBatteryStatusUpdate = now
+ }
+
+ if Thread.isMainThread {
+ applyUpdates()
+ } else {
+ DispatchQueue.main.sync(execute: applyUpdates)
+ }
+ }
+
+ private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
+ guard !source.isEmpty else { return }
+ for (key, value) in source {
+ guard !key.isEmpty else { continue }
+ if let existing = target[key] {
+ target[key] = max(existing, value)
+ } else {
+ target[key] = value
+ }
+ }
+ }
+
+ private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
+ var addressPercentages: [String: Int] = [:]
+ var namePercentages: [String: Int] = [:]
+
+ var iterator = io_iterator_t()
+ let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
+
+ let servicePort: mach_port_t
+ if #available(macOS 12.0, *) {
+ servicePort = kIOMainPortDefault
+ } else {
+ servicePort = kIOMasterPortDefault
+ }
+
+ let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
+
+ if kernResult == KERN_SUCCESS {
+ var entry: io_object_t = IOIteratorNext(iterator)
+ while entry != 0 {
+ if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
+ let normalizedPercent = clampBatteryPercentage(percent)
+
+ let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
+ for key in identifierKeys {
+ if let identifier = stringValue(forKey: key, entry: entry) {
+ let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
+ if !normalizedIdentifier.isEmpty {
+ if let existing = addressPercentages[normalizedIdentifier] {
+ addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
+ } else {
+ addressPercentages[normalizedIdentifier] = normalizedPercent
+ }
+ }
+ }
+ }
+
+ let nameKeys = [
+ "Product",
+ "ProductName",
+ "DeviceName",
+ "Name",
+ "USB Product Name",
+ "Bluetooth Product Name"
+ ]
+
+ for key in nameKeys {
+ if let product = stringValue(forKey: key, entry: entry) {
+ let normalizedName = normalizeProductName(product)
+ if !normalizedName.isEmpty {
+ if let existing = namePercentages[normalizedName] {
+ namePercentages[normalizedName] = max(existing, normalizedPercent)
+ } else {
+ namePercentages[normalizedName] = normalizedPercent
+ }
+ }
+ }
+ }
+ }
+
+ IOObjectRelease(entry)
+ entry = IOIteratorNext(iterator)
+ }
+ }
+
+ IOObjectRelease(iterator)
+
+ return (addressPercentages, namePercentages)
+ }
+
+ private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
+ guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
+ let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
+ return ([:], [:])
+ }
+
+ var addressPercentages: [String: Int] = [:]
+ var namePercentages: [String: Int] = [:]
+
+ for (key, value) in deviceCache {
+ guard let payload = value as? [String: Any] else { continue }
+ guard let level = extractBatteryPercentage(from: payload) else { continue }
+ let clamped = clampBatteryPercentage(level)
+
+ let normalizedKey = normalizeBluetoothIdentifier(key)
+ if !normalizedKey.isEmpty {
+ addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
+ }
+
+ for identifier in identifiersFromDeviceCachePayload(payload) {
+ addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
+ }
+
+ if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
+ let normalizedName = normalizeProductName(name)
+ if !normalizedName.isEmpty {
+ namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
+ }
+ }
+ }
+
+ return (addressPercentages, namePercentages)
+ }
+
+ private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
+ guard let root = systemProfilerBluetoothDictionary() else {
+ return ([:], [:])
+ }
+
+ var addressPercentages: [String: Int] = [:]
+ var namePercentages: [String: Int] = [:]
+
+ if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
+ for deviceGroup in connectedList {
+ for (rawName, payload) in deviceGroup {
+ guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
+ let clamped = clampBatteryPercentage(percent)
+
+ let normalizedName = normalizeProductName(rawName)
+ if !normalizedName.isEmpty {
+ namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
+ }
+
+ for address in profilerAddressCandidates(from: payload) {
+ let normalizedAddress = normalizeBluetoothIdentifier(address)
+ if !normalizedAddress.isEmpty {
+ addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
+ }
+ }
+ }
+ }
+ }
+
+ return (addressPercentages, namePercentages)
+ }
+
+ private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
+ var identifiers: Set = []
+ 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 = []
+ 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
@@ -382,11 +1004,19 @@ 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(
@@ -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 {
diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift
index ad54397..1ed694f 100644
--- a/DynamicIsland/managers/LockScreenPanelManager.swift
+++ b/DynamicIsland/managers/LockScreenPanelManager.swift
@@ -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() {
diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift
index cb0c272..4eb74b6 100644
--- a/DynamicIsland/managers/LockScreenWeatherManager.swift
+++ b/DynamicIsland/managers/LockScreenWeatherManager.swift
@@ -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()
- 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] = [
+ 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] = [
+ 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 {
diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift
index 76fef4f..329e808 100644
--- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift
+++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift
@@ -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)
}
}
diff --git a/DynamicIsland/managers/MacBatteryManager.swift b/DynamicIsland/managers/MacBatteryManager.swift
new file mode 100644
index 0000000..fe8ee69
--- /dev/null
+++ b/DynamicIsland/managers/MacBatteryManager.swift
@@ -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"
+ }
+}
diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift
index 8e1ca19..44441ec 100644
--- a/DynamicIsland/managers/SystemChangesObserver.swift
+++ b/DynamicIsland/managers/SystemChangesObserver.swift
@@ -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,
diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift
index a5add30..9e41d55 100644
--- a/DynamicIsland/managers/SystemMediaControllers.swift
+++ b/DynamicIsland/managers/SystemMediaControllers.swift
@@ -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(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.size)
+ return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
+ }
+
+ private func setData(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.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) }
}
}
diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift
new file mode 100644
index 0000000..5eed75f
--- /dev/null
+++ b/DynamicIsland/managers/TimerControlWindowManager.swift
@@ -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?
+ 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 {
+ 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) -> 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
diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift
index 06adfd9..f133c5d 100644
--- a/DynamicIsland/managers/TimerManager.swift
+++ b/DynamicIsland/managers/TimerManager.swift
@@ -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()
@@ -140,8 +142,8 @@ class TimerManager: ObservableObject {
elapsedTime = 0
isPaused = false
lastUpdated = Date()
-
- triggerTimerSneakPeek()
+
+ activePresetId = preset?.id
// Start countdown timer
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@@ -250,8 +252,15 @@ 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() {
// Wait 3 seconds then smoothly close the live activity
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
@@ -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 {
diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift
index 503bf36..a02af29 100644
--- a/DynamicIsland/models/Constants.swift
+++ b/DynamicIsland/models/Constants.swift
@@ -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("enableFullscreenMediaDetection", default: true)
static let waitInterval = Key("waitInterval", default: 3)
static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false)
+ static let showMediaOutputControl = Key("showMediaOutputControl", default: false)
// Enable lock screen media widget (shows the standalone panel when screen is locked)
static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true)
static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60)
+ static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true)
+ static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true)
+ static let lockScreenWeatherShowsChargingPercentage = Key("lockScreenWeatherShowsChargingPercentage", default: true)
+ static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true)
+ static let lockScreenWeatherShowsBatteryGauge = Key("lockScreenWeatherShowsBatteryGauge", default: true)
+ static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular)
+ static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius)
+ static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false)
+ static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false)
+ static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo)
static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid)
static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)
@@ -362,6 +394,7 @@ extension Defaults.Keys {
static let enableGradient = Key("enableGradient", default: false)
static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false)
static let systemEventIndicatorUseAccent = Key("systemEventIndicatorUseAccent", default: false)
+ static let showProgressPercentages = Key("showProgressPercentages", default: false)
// MARK: Shelf
static let dynamicShelf = Key("dynamicShelf", default: true)
@@ -386,6 +419,9 @@ extension Defaults.Keys {
static let useColorCodedBatteryDisplay = Key("useColorCodedBatteryDisplay", default: true)
static let useColorCodedVolumeDisplay = Key("useColorCodedVolumeDisplay", default: false)
static let useSmoothColorGradient = Key("useSmoothColorGradient", default: true)
+ static let useCircularBluetoothBatteryIndicator = Key("useCircularBluetoothBatteryIndicator", default: true)
+ static let showBluetoothBatteryPercentageText = Key("showBluetoothBatteryPercentageText", default: true)
+ static let showBluetoothDeviceNameMarquee = Key("showBluetoothDeviceNameMarquee", default: true)
// MARK: Stats Feature
static let enableStatsFeature = Key("enableStatsFeature", default: false)
@@ -399,6 +435,14 @@ extension Defaults.Keys {
// MARK: Timer Feature
static let enableTimerFeature = Key("enableTimerFeature", default: true)
+ static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
+ static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive)
+ static let timerSolidColor = Key("timerSolidColor", default: .blue)
+ static let timerShowsCountdown = Key("timerShowsCountdown", default: true)
+ static let timerShowsLabel = Key("timerShowsLabel", default: true)
+ static let timerShowsProgress = Key("timerShowsProgress", default: true)
+ static let timerProgressStyle = Key("timerProgressStyle", default: .bar)
+ static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature
static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true)
diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift
index ae057bf..c596a55 100644
--- a/DynamicIsland/models/DynamicIslandViewModel.swift
+++ b/DynamicIsland/models/DynamicIslandViewModel.swift
@@ -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
diff --git a/DynamicIsland/models/TimerPreset.swift b/DynamicIsland/models/TimerPreset.swift
new file mode 100644
index 0000000..e2630fb
--- /dev/null
+++ b/DynamicIsland/models/TimerPreset.swift
@@ -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)
+ ]
+}
diff --git a/DynamicIslandSamples/dynamicislandscreenrecord.gif b/DynamicIslandSamples/dynamicislandscreenrecord.gif
index 9ed8021..7e13136 100644
Binary files a/DynamicIslandSamples/dynamicislandscreenrecord.gif and b/DynamicIslandSamples/dynamicislandscreenrecord.gif differ
diff --git a/ReadMe.md b/ReadMe.md
index acf5527..d69bdc9 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -2,7 +2,26 @@
-> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Join our Discord community
+
+
+**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
+### LockScreen Widgets
+- **Weather**
+- **Media Player**
+- **Device Charging status**
+- **Bluetooth Peripheral Battery status**
+
+
+
+
+
### 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.
+
+
+
+
+
## 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.
+
+
+
+
+
### 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
+
+
+
+
+## Star History
+
+[](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
\ No newline at end of file